mirror of
https://github.com/unraid/api.git
synced 2026-01-06 00:30:22 -06:00
Compare commits
14 Commits
4.9.0-buil
...
4.9.3-buil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e323f2a613 | ||
|
|
1f49023571 | ||
|
|
33e0b1ab24 | ||
|
|
ca4e2db1f2 | ||
|
|
ea20d1e211 | ||
|
|
79c57b8ed0 | ||
|
|
4168f43e3e | ||
|
|
20de3ec8d6 | ||
|
|
39b8f453da | ||
|
|
6bf3f77638 | ||
|
|
a79d049865 | ||
|
|
5b6bcb6043 | ||
|
|
6ee3cae962 | ||
|
|
f3671c3e07 |
@@ -2,7 +2,19 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(rg:*)",
|
||||
"Bash(find:*)"
|
||||
"Bash(find:*)",
|
||||
"Bash(pnpm codegen:*)",
|
||||
"Bash(pnpm dev:*)",
|
||||
"Bash(pnpm build:*)",
|
||||
"Bash(pnpm test:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(pnpm type-check:*)",
|
||||
"Bash(pnpm lint:*)",
|
||||
"Bash(pnpm --filter ./api lint)",
|
||||
"Bash(mv:*)",
|
||||
"Bash(ls:*)",
|
||||
"mcp__ide__getDiagnostics",
|
||||
"Bash(pnpm --filter \"*connect*\" test connect-status-writer.service.spec)"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": false
|
||||
|
||||
2
.github/workflows/deploy-storybook.yml
vendored
2
.github/workflows/deploy-storybook.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '20.19.3'
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
|
||||
4
.github/workflows/release-production.yml
vendored
4
.github/workflows/release-production.yml
vendored
@@ -32,7 +32,9 @@ jobs:
|
||||
with:
|
||||
node-version: '22.17.0'
|
||||
- run: |
|
||||
echo '${{ steps.release-info.outputs.body }}' >> release-notes.txt
|
||||
cat << 'EOF' > release-notes.txt
|
||||
${{ steps.release-info.outputs.body }}
|
||||
EOF
|
||||
- run: npm install html-escaper@2 xml2js
|
||||
- name: Update Plugin Changelog
|
||||
uses: actions/github-script@v7
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"4.9.0"}
|
||||
{".":"4.9.3"}
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## [4.9.3](https://github.com/unraid/api/compare/v4.9.2...v4.9.3) (2025-07-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* duplicated header logo after api stops ([#1493](https://github.com/unraid/api/issues/1493)) ([4168f43](https://github.com/unraid/api/commit/4168f43e3ecd51479bec3aae585abbe6dcd3e416))
|
||||
|
||||
## [4.9.2](https://github.com/unraid/api/compare/v4.9.1...v4.9.2) (2025-07-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* invalid configs no longer crash API ([#1491](https://github.com/unraid/api/issues/1491)) ([6bf3f77](https://github.com/unraid/api/commit/6bf3f776380edeff5133517e6aca223556e30144))
|
||||
* invalid state for unraid plugin ([#1492](https://github.com/unraid/api/issues/1492)) ([39b8f45](https://github.com/unraid/api/commit/39b8f453da23793ef51f8e7f7196370aada8c5aa))
|
||||
* release note escaping ([5b6bcb6](https://github.com/unraid/api/commit/5b6bcb6043a5269bff4dc28714d787a5a3f07e22))
|
||||
|
||||
## [4.9.1](https://github.com/unraid/api/compare/v4.9.0...v4.9.1) (2025-07-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **HeaderOsVersion:** adjust top margin for header component ([#1485](https://github.com/unraid/api/issues/1485)) ([862b54d](https://github.com/unraid/api/commit/862b54de8cd793606f1d29e76c19d4a0e1ae172f))
|
||||
* sign out doesn't work ([#1486](https://github.com/unraid/api/issues/1486)) ([f3671c3](https://github.com/unraid/api/commit/f3671c3e0750b79be1f19655a07a0e9932289b3f))
|
||||
|
||||
## [4.9.0](https://github.com/unraid/api/compare/v4.8.0...v4.9.0) (2025-07-08)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/api",
|
||||
"version": "4.9.0",
|
||||
"version": "4.9.3",
|
||||
"main": "src/cli/index.ts",
|
||||
"type": "module",
|
||||
"corepack": {
|
||||
|
||||
@@ -124,7 +124,15 @@ export const parseConfig = <T extends Record<string, any>>(
|
||||
throw new AppError('Invalid Parameters Passed to ParseConfig');
|
||||
}
|
||||
|
||||
const data: Record<string, any> = parseIni(fileContents);
|
||||
let data: Record<string, any>;
|
||||
try {
|
||||
data = parseIni(fileContents);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
`Failed to parse config file: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Remove quotes around keys
|
||||
const dataWithoutQuoteKeys = Object.fromEntries(
|
||||
Object.entries(data).map(([key, value]) => [key.replace(/^"(.+(?="$))"$/, '$1'), value])
|
||||
|
||||
@@ -12,6 +12,8 @@ import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.help
|
||||
|
||||
export { type ApiConfig };
|
||||
|
||||
const logger = new Logger('ApiConfig');
|
||||
|
||||
const createDefaultConfig = (): ApiConfig => ({
|
||||
version: API_VERSION,
|
||||
extraOrigins: [],
|
||||
@@ -33,21 +35,54 @@ export const persistApiConfig = async (config: ApiConfig) => {
|
||||
};
|
||||
|
||||
export const loadApiConfig = async () => {
|
||||
const defaultConfig = createDefaultConfig();
|
||||
const apiConfig = new ApiStateConfig<ApiConfig>(
|
||||
{
|
||||
name: 'api',
|
||||
defaultConfig,
|
||||
parse: (data) => data as ApiConfig,
|
||||
},
|
||||
new ConfigPersistenceHelper()
|
||||
);
|
||||
const diskConfig = await apiConfig.parseConfig();
|
||||
return {
|
||||
...defaultConfig,
|
||||
...diskConfig,
|
||||
version: API_VERSION,
|
||||
};
|
||||
try {
|
||||
const defaultConfig = createDefaultConfig();
|
||||
const apiConfig = new ApiStateConfig<ApiConfig>(
|
||||
{
|
||||
name: 'api',
|
||||
defaultConfig,
|
||||
parse: (data) => data as ApiConfig,
|
||||
},
|
||||
new ConfigPersistenceHelper()
|
||||
);
|
||||
|
||||
let diskConfig: ApiConfig | undefined;
|
||||
try {
|
||||
diskConfig = await apiConfig.parseConfig();
|
||||
} catch (error) {
|
||||
logger.error('Failed to load API config from disk, using defaults:', error);
|
||||
diskConfig = undefined;
|
||||
|
||||
// Try to overwrite the invalid config with defaults to fix the issue
|
||||
try {
|
||||
const configToWrite = {
|
||||
...defaultConfig,
|
||||
version: API_VERSION,
|
||||
};
|
||||
|
||||
const writeSuccess = await apiConfig.persist(configToWrite);
|
||||
if (writeSuccess) {
|
||||
logger.log('Successfully overwrote invalid config file with defaults.');
|
||||
} else {
|
||||
logger.error(
|
||||
'Failed to overwrite invalid config file. Continuing with defaults in memory only.'
|
||||
);
|
||||
}
|
||||
} catch (persistError) {
|
||||
logger.error('Error during config file repair:', persistError);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...defaultConfig,
|
||||
...diskConfig,
|
||||
version: API_VERSION,
|
||||
};
|
||||
} catch (outerError) {
|
||||
// This should never happen, but ensures the config factory never throws
|
||||
logger.error('Critical error in loadApiConfig, using minimal defaults:', outerError);
|
||||
return createDefaultConfig();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -81,21 +116,29 @@ export class ApiConfigPersistence {
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
if (!(await fileExists(this.filePath))) {
|
||||
this.migrateFromMyServersConfig();
|
||||
try {
|
||||
if (!(await fileExists(this.filePath))) {
|
||||
this.migrateFromMyServersConfig();
|
||||
}
|
||||
await this.persistenceHelper.persistIfChanged(this.filePath, this.config);
|
||||
this.configService.changes$.pipe(bufferTime(25)).subscribe({
|
||||
next: async (changes) => {
|
||||
if (changes.some((change) => change.path.startsWith('api'))) {
|
||||
this.logger.verbose(`API Config changed ${JSON.stringify(changes)}`);
|
||||
try {
|
||||
await this.persistenceHelper.persistIfChanged(this.filePath, this.config);
|
||||
} catch (persistError) {
|
||||
this.logger.error('Error persisting config changes:', persistError);
|
||||
}
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.logger.error('Error receiving config changes:', err);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Error during API config module initialization:', error);
|
||||
}
|
||||
await this.persistenceHelper.persistIfChanged(this.filePath, this.config);
|
||||
this.configService.changes$.pipe(bufferTime(25)).subscribe({
|
||||
next: async (changes) => {
|
||||
if (changes.some((change) => change.path.startsWith('api'))) {
|
||||
this.logger.verbose(`API Config changed ${JSON.stringify(changes)}`);
|
||||
await this.persistenceHelper.persistIfChanged(this.filePath, this.config);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.logger.error('Error receiving config changes:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
convertLegacyConfig(
|
||||
|
||||
@@ -2,9 +2,26 @@ import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiConfigPersistence } from '@app/unraid-api/config/api-config.module.js';
|
||||
import { fileExists } from '@app/core/utils/files/file-exists.js';
|
||||
import { ApiConfigPersistence, loadApiConfig } from '@app/unraid-api/config/api-config.module.js';
|
||||
import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.helper.js';
|
||||
|
||||
// Mock the core file-exists utility used by ApiStateConfig
|
||||
vi.mock('@app/core/utils/files/file-exists.js', () => ({
|
||||
fileExists: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the shared file-exists utility used by ConfigPersistenceHelper
|
||||
vi.mock('@unraid/shared/util/file.js', () => ({
|
||||
fileExists: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock fs/promises for file I/O operations
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('ApiConfigPersistence', () => {
|
||||
let service: ApiConfigPersistence;
|
||||
let configService: ConfigService;
|
||||
@@ -135,3 +152,127 @@ describe('ApiConfigPersistence', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadApiConfig', () => {
|
||||
let readFile: any;
|
||||
let writeFile: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
// Reset modules to ensure fresh imports
|
||||
vi.resetModules();
|
||||
|
||||
// Get mocked functions
|
||||
const fsMocks = await import('fs/promises');
|
||||
readFile = fsMocks.readFile;
|
||||
writeFile = fsMocks.writeFile;
|
||||
});
|
||||
|
||||
it('should return default config when file does not exist', async () => {
|
||||
vi.mocked(fileExists).mockResolvedValue(false);
|
||||
|
||||
const result = await loadApiConfig();
|
||||
|
||||
expect(result).toEqual({
|
||||
version: expect.any(String),
|
||||
extraOrigins: [],
|
||||
sandbox: false,
|
||||
ssoSubIds: [],
|
||||
plugins: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should merge disk config with defaults when file exists', async () => {
|
||||
const diskConfig = {
|
||||
extraOrigins: ['https://example.com'],
|
||||
sandbox: true,
|
||||
ssoSubIds: ['sub1', 'sub2'],
|
||||
};
|
||||
|
||||
vi.mocked(fileExists).mockResolvedValue(true);
|
||||
vi.mocked(readFile).mockResolvedValue(JSON.stringify(diskConfig));
|
||||
|
||||
const result = await loadApiConfig();
|
||||
|
||||
expect(result).toEqual({
|
||||
version: expect.any(String),
|
||||
extraOrigins: ['https://example.com'],
|
||||
sandbox: true,
|
||||
ssoSubIds: ['sub1', 'sub2'],
|
||||
plugins: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default config and overwrite file when JSON parsing fails', async () => {
|
||||
const { fileExists: sharedFileExists } = await import('@unraid/shared/util/file.js');
|
||||
|
||||
vi.mocked(fileExists).mockResolvedValue(true);
|
||||
vi.mocked(readFile).mockResolvedValue('{ invalid json }');
|
||||
vi.mocked(sharedFileExists).mockResolvedValue(false); // For persist operation
|
||||
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const result = await loadApiConfig();
|
||||
|
||||
// Error logging is handled by NestJS Logger, just verify the config is returned
|
||||
expect(writeFile).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
version: expect.any(String),
|
||||
extraOrigins: [],
|
||||
sandbox: false,
|
||||
ssoSubIds: [],
|
||||
plugins: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle write failure gracefully when JSON parsing fails', async () => {
|
||||
const { fileExists: sharedFileExists } = await import('@unraid/shared/util/file.js');
|
||||
|
||||
vi.mocked(fileExists).mockResolvedValue(true);
|
||||
vi.mocked(readFile).mockResolvedValue('{ invalid json }');
|
||||
vi.mocked(sharedFileExists).mockResolvedValue(false); // For persist operation
|
||||
vi.mocked(writeFile).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
const result = await loadApiConfig();
|
||||
|
||||
// Error logging is handled by NestJS Logger, just verify the config is returned
|
||||
expect(writeFile).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
version: expect.any(String),
|
||||
extraOrigins: [],
|
||||
sandbox: false,
|
||||
ssoSubIds: [],
|
||||
plugins: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default config when file is empty', async () => {
|
||||
vi.mocked(fileExists).mockResolvedValue(true);
|
||||
vi.mocked(readFile).mockResolvedValue('');
|
||||
|
||||
const result = await loadApiConfig();
|
||||
|
||||
// No error logging expected for empty files
|
||||
expect(result).toEqual({
|
||||
version: expect.any(String),
|
||||
extraOrigins: [],
|
||||
sandbox: false,
|
||||
ssoSubIds: [],
|
||||
plugins: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should always override version with current API_VERSION', async () => {
|
||||
const diskConfig = {
|
||||
version: 'old-version',
|
||||
extraOrigins: ['https://example.com'],
|
||||
};
|
||||
|
||||
vi.mocked(fileExists).mockResolvedValue(true);
|
||||
vi.mocked(readFile).mockResolvedValue(JSON.stringify(diskConfig));
|
||||
|
||||
const result = await loadApiConfig();
|
||||
|
||||
expect(result.version).not.toBe('old-version');
|
||||
expect(result.version).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
364
api/src/unraid-api/config/factory/api-state.model.test.ts
Normal file
364
api/src/unraid-api/config/factory/api-state.model.test.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import type { Mock } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fileExists } from '@app/core/utils/files/file-exists.js';
|
||||
import { ApiStateConfig } from '@app/unraid-api/config/factory/api-state.model.js';
|
||||
import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.helper.js';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('@app/core/utils/files/file-exists.js');
|
||||
vi.mock('@app/environment.js', () => ({
|
||||
PATHS_CONFIG_MODULES: '/test/config/path',
|
||||
}));
|
||||
|
||||
describe('ApiStateConfig', () => {
|
||||
let mockPersistenceHelper: ConfigPersistenceHelper;
|
||||
let mockLogger: Logger;
|
||||
|
||||
interface TestConfig {
|
||||
name: string;
|
||||
value: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const defaultConfig: TestConfig = {
|
||||
name: 'test',
|
||||
value: 42,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const parseFunction = (data: unknown): TestConfig => {
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Invalid config format');
|
||||
}
|
||||
return data as TestConfig;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockPersistenceHelper = {
|
||||
persistIfChanged: vi.fn().mockResolvedValue(true),
|
||||
} as any;
|
||||
|
||||
mockLogger = {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
} as any;
|
||||
|
||||
vi.spyOn(Logger.prototype, 'log').mockImplementation(mockLogger.log);
|
||||
vi.spyOn(Logger.prototype, 'warn').mockImplementation(mockLogger.warn);
|
||||
vi.spyOn(Logger.prototype, 'error').mockImplementation(mockLogger.error);
|
||||
vi.spyOn(Logger.prototype, 'debug').mockImplementation(mockLogger.debug);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with cloned default config', () => {
|
||||
const config = new ApiStateConfig(
|
||||
{
|
||||
name: 'test-config',
|
||||
defaultConfig,
|
||||
parse: parseFunction,
|
||||
},
|
||||
mockPersistenceHelper
|
||||
);
|
||||
|
||||
expect(config.config).toEqual(defaultConfig);
|
||||
expect(config.config).not.toBe(defaultConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe('token', () => {
|
||||
it('should generate correct token', () => {
|
||||
const config = new ApiStateConfig(
|
||||
{
|
||||
name: 'my-config',
|
||||
defaultConfig,
|
||||
parse: parseFunction,
|
||||
},
|
||||
mockPersistenceHelper
|
||||
);
|
||||
|
||||
expect(config.token).toBe('ApiConfig.my-config');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file paths', () => {
|
||||
it('should generate correct file name', () => {
|
||||
const config = new ApiStateConfig(
|
||||
{
|
||||
name: 'test-config',
|
||||
defaultConfig,
|
||||
parse: parseFunction,
|
||||
},
|
||||
mockPersistenceHelper
|
||||
);
|
||||
|
||||
expect(config.fileName).toBe('test-config.json');
|
||||
});
|
||||
|
||||
it('should generate correct file path', () => {
|
||||
const config = new ApiStateConfig(
|
||||
{
|
||||
name: 'test-config',
|
||||
defaultConfig,
|
||||
parse: parseFunction,
|
||||
},
|
||||
mockPersistenceHelper
|
||||
);
|
||||
|
||||
expect(config.filePath).toBe(join('/test/config/path', 'test-config.json'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseConfig', () => {
|
||||
let config: ApiStateConfig<TestConfig>;
|
||||
|
||||
beforeEach(() => {
|
||||
config = new ApiStateConfig(
|
||||
{
|
||||
name: 'test-config',
|
||||
defaultConfig,
|
||||
parse: parseFunction,
|
||||
},
|
||||
mockPersistenceHelper
|
||||
);
|
||||
});
|
||||
|
||||
it('should return undefined when file does not exist', async () => {
|
||||
(fileExists as Mock).mockResolvedValue(false);
|
||||
|
||||
const result = await config.parseConfig();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should parse valid JSON config', async () => {
|
||||
const validConfig = { name: 'custom', value: 100, enabled: false };
|
||||
(fileExists as Mock).mockResolvedValue(true);
|
||||
(readFile as Mock).mockResolvedValue(JSON.stringify(validConfig));
|
||||
|
||||
const result = await config.parseConfig();
|
||||
|
||||
expect(result).toEqual(validConfig);
|
||||
expect(readFile).toHaveBeenCalledWith(config.filePath, 'utf8');
|
||||
});
|
||||
|
||||
it('should return undefined for empty file', async () => {
|
||||
(fileExists as Mock).mockResolvedValue(true);
|
||||
(readFile as Mock).mockResolvedValue('');
|
||||
|
||||
const result = await config.parseConfig();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('is empty'));
|
||||
});
|
||||
|
||||
it('should return undefined for whitespace-only file', async () => {
|
||||
(fileExists as Mock).mockResolvedValue(true);
|
||||
(readFile as Mock).mockResolvedValue(' \n\t ');
|
||||
|
||||
const result = await config.parseConfig();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('is empty'));
|
||||
});
|
||||
|
||||
it('should throw error for invalid JSON', async () => {
|
||||
(fileExists as Mock).mockResolvedValue(true);
|
||||
(readFile as Mock).mockResolvedValue('{ invalid json }');
|
||||
|
||||
await expect(config.parseConfig()).rejects.toThrow();
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to parse JSON')
|
||||
);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('{ invalid json }'));
|
||||
});
|
||||
|
||||
it('should throw error for incomplete JSON', async () => {
|
||||
(fileExists as Mock).mockResolvedValue(true);
|
||||
(readFile as Mock).mockResolvedValue('{ "name": "test"');
|
||||
|
||||
await expect(config.parseConfig()).rejects.toThrow();
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to parse JSON')
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom file path when provided', async () => {
|
||||
const customPath = '/custom/path/config.json';
|
||||
(fileExists as Mock).mockResolvedValue(true);
|
||||
(readFile as Mock).mockResolvedValue(JSON.stringify(defaultConfig));
|
||||
|
||||
await config.parseConfig({ filePath: customPath });
|
||||
|
||||
expect(fileExists).toHaveBeenCalledWith(customPath);
|
||||
expect(readFile).toHaveBeenCalledWith(customPath, 'utf8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('persist', () => {
|
||||
let config: ApiStateConfig<TestConfig>;
|
||||
|
||||
beforeEach(() => {
|
||||
config = new ApiStateConfig(
|
||||
{
|
||||
name: 'test-config',
|
||||
defaultConfig,
|
||||
parse: parseFunction,
|
||||
},
|
||||
mockPersistenceHelper
|
||||
);
|
||||
});
|
||||
|
||||
it('should persist current config when no argument provided', async () => {
|
||||
const result = await config.persist();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPersistenceHelper.persistIfChanged).toHaveBeenCalledWith(
|
||||
config.filePath,
|
||||
defaultConfig
|
||||
);
|
||||
});
|
||||
|
||||
it('should persist provided config', async () => {
|
||||
const customConfig = { name: 'custom', value: 999, enabled: false };
|
||||
|
||||
const result = await config.persist(customConfig);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPersistenceHelper.persistIfChanged).toHaveBeenCalledWith(
|
||||
config.filePath,
|
||||
customConfig
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false and log error on persistence failure', async () => {
|
||||
(mockPersistenceHelper.persistIfChanged as Mock).mockResolvedValue(false);
|
||||
|
||||
const result = await config.persist();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Could not write config')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('load', () => {
|
||||
let config: ApiStateConfig<TestConfig>;
|
||||
|
||||
beforeEach(() => {
|
||||
config = new ApiStateConfig(
|
||||
{
|
||||
name: 'test-config',
|
||||
defaultConfig,
|
||||
parse: parseFunction,
|
||||
},
|
||||
mockPersistenceHelper
|
||||
);
|
||||
});
|
||||
|
||||
it('should load config from file when it exists', async () => {
|
||||
const savedConfig = { name: 'saved', value: 200, enabled: true };
|
||||
(fileExists as Mock).mockResolvedValue(true);
|
||||
(readFile as Mock).mockResolvedValue(JSON.stringify(savedConfig));
|
||||
|
||||
await config.load();
|
||||
|
||||
expect(config.config).toEqual(savedConfig);
|
||||
});
|
||||
|
||||
it('should create default config when file does not exist', async () => {
|
||||
(fileExists as Mock).mockResolvedValue(false);
|
||||
|
||||
await config.load();
|
||||
|
||||
expect(config.config).toEqual(defaultConfig);
|
||||
expect(mockLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Config file does not exist')
|
||||
);
|
||||
expect(mockPersistenceHelper.persistIfChanged).toHaveBeenCalledWith(
|
||||
config.filePath,
|
||||
defaultConfig
|
||||
);
|
||||
});
|
||||
|
||||
it('should not modify config when file is invalid', async () => {
|
||||
(fileExists as Mock).mockResolvedValue(true);
|
||||
(readFile as Mock).mockResolvedValue('invalid json');
|
||||
|
||||
await config.load();
|
||||
|
||||
expect(config.config).toEqual(defaultConfig);
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
expect.stringContaining('is invalid')
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw even when persist fails', async () => {
|
||||
(fileExists as Mock).mockResolvedValue(false);
|
||||
(mockPersistenceHelper.persistIfChanged as Mock).mockResolvedValue(false);
|
||||
|
||||
await expect(config.load()).resolves.not.toThrow();
|
||||
|
||||
expect(config.config).toEqual(defaultConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
let config: ApiStateConfig<TestConfig>;
|
||||
|
||||
beforeEach(() => {
|
||||
config = new ApiStateConfig(
|
||||
{
|
||||
name: 'test-config',
|
||||
defaultConfig,
|
||||
parse: parseFunction,
|
||||
},
|
||||
mockPersistenceHelper
|
||||
);
|
||||
});
|
||||
|
||||
it('should update config with partial values', () => {
|
||||
config.update({ value: 123 });
|
||||
|
||||
expect(config.config).toEqual({
|
||||
name: 'test',
|
||||
value: 123,
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return self for chaining', () => {
|
||||
const result = config.update({ enabled: false });
|
||||
|
||||
expect(result).toBe(config);
|
||||
});
|
||||
|
||||
it('should validate updated config through parse function', () => {
|
||||
const badParseFunction = vi.fn().mockImplementation(() => {
|
||||
throw new Error('Validation failed');
|
||||
});
|
||||
|
||||
const strictConfig = new ApiStateConfig(
|
||||
{
|
||||
name: 'strict-config',
|
||||
defaultConfig,
|
||||
parse: badParseFunction,
|
||||
},
|
||||
mockPersistenceHelper
|
||||
);
|
||||
|
||||
expect(() => strictConfig.update({ value: -1 })).toThrow('Validation failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -56,13 +56,11 @@ export class ApiStateConfig<T> {
|
||||
* @returns True if the config was written successfully, false otherwise.
|
||||
*/
|
||||
async persist(config = this.#config) {
|
||||
try {
|
||||
await this.persistenceHelper.persistIfChanged(this.filePath, config);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(error, `Could not write config to ${this.filePath}.`);
|
||||
return false;
|
||||
const success = await this.persistenceHelper.persistIfChanged(this.filePath, config);
|
||||
if (!success) {
|
||||
this.logger.error(`Could not write config to ${this.filePath}.`);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,8 +74,23 @@ export class ApiStateConfig<T> {
|
||||
const { filePath = this.filePath } = opts;
|
||||
if (!(await fileExists(filePath))) return undefined;
|
||||
|
||||
const rawConfig = JSON.parse(await readFile(filePath, 'utf8'));
|
||||
return this.options.parse(rawConfig);
|
||||
const fileContent = await readFile(filePath, 'utf8');
|
||||
|
||||
if (!fileContent || fileContent.trim() === '') {
|
||||
this.logger.warn(`Config file '${filePath}' is empty.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawConfig = JSON.parse(fileContent);
|
||||
return this.options.parse(rawConfig);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to parse JSON from '${filePath}': ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
this.logger.debug(`File content: ${fileContent.substring(0, 100)}...`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,24 +12,59 @@ export class ConfigPersistenceHelper {
|
||||
*
|
||||
* @param filePath - The path to the config file.
|
||||
* @param data - The data to persist.
|
||||
* @returns `true` if the config was persisted, `false` otherwise.
|
||||
* @returns `true` if the config was persisted, `false` if no changes were needed or if persistence failed.
|
||||
*
|
||||
* @throws {Error} if the config file does not exist or is unreadable.
|
||||
* @throws {Error} if the config file is not valid JSON.
|
||||
* @throws {Error} if given data is not JSON (de)serializable.
|
||||
* @throws {Error} if the config file is not writable.
|
||||
* This method is designed to never throw errors. If the existing file is corrupted or unreadable,
|
||||
* it will attempt to overwrite it with the new data. If write operations fail, it returns false
|
||||
* but does not crash the application.
|
||||
*/
|
||||
async persistIfChanged(filePath: string, data: unknown): Promise<boolean> {
|
||||
if (!(await fileExists(filePath))) {
|
||||
await writeFile(filePath, JSON.stringify(data ?? {}, null, 2));
|
||||
return true;
|
||||
try {
|
||||
const jsonString = JSON.stringify(data ?? {}, null, 2);
|
||||
await writeFile(filePath, jsonString);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// JSON serialization or write failed, but don't crash - just return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const currentData = JSON.parse(await readFile(filePath, 'utf8'));
|
||||
const stagedData = JSON.parse(JSON.stringify(data));
|
||||
|
||||
let currentData: unknown;
|
||||
try {
|
||||
const fileContent = await readFile(filePath, 'utf8');
|
||||
currentData = JSON.parse(fileContent);
|
||||
} catch (error) {
|
||||
// If existing file is corrupted, treat it as if it doesn't exist
|
||||
// and write the new data
|
||||
try {
|
||||
const jsonString = JSON.stringify(data ?? {}, null, 2);
|
||||
await writeFile(filePath, jsonString);
|
||||
return true;
|
||||
} catch (writeError) {
|
||||
// JSON serialization or write failed, but don't crash - just return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let stagedData: unknown;
|
||||
try {
|
||||
stagedData = JSON.parse(JSON.stringify(data));
|
||||
} catch (error) {
|
||||
// If data can't be serialized to JSON, we can't persist it
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isEqual(currentData, stagedData)) {
|
||||
return false;
|
||||
}
|
||||
await writeFile(filePath, JSON.stringify(stagedData, null, 2));
|
||||
return true;
|
||||
|
||||
try {
|
||||
await writeFile(filePath, JSON.stringify(stagedData, null, 2));
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Write failed, but don't crash - just return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,16 @@ export class PluginService {
|
||||
* @returns A tuple of the plugin name and version.
|
||||
*/
|
||||
static async listPlugins(): Promise<[string, string][]> {
|
||||
const { plugins = [] } = await loadApiConfig();
|
||||
let plugins: string[] = [];
|
||||
try {
|
||||
const config = await loadApiConfig();
|
||||
plugins = config.plugins || [];
|
||||
} catch (error) {
|
||||
PluginService.logger.error(
|
||||
'Failed to load API config for plugin discovery, using empty list:',
|
||||
error
|
||||
);
|
||||
}
|
||||
const pluginNames = new Set(
|
||||
plugins.map((plugin) => {
|
||||
const { name } = parsePackageArg(plugin);
|
||||
|
||||
@@ -65,6 +65,13 @@ if (is_localhost() && !is_good_session()) {
|
||||
return this.prependDoctypeWithPhp(source, newPhpCode);
|
||||
}
|
||||
|
||||
private addModalsWebComponent(source: string): string {
|
||||
if (source.includes('<unraid-modals>')) {
|
||||
return source;
|
||||
}
|
||||
return source.replace('<body>', '<body>\n<unraid-modals></unraid-modals>');
|
||||
}
|
||||
|
||||
private hideHeaderLogo(source: string): string {
|
||||
return source.replace(
|
||||
'<a href="https://unraid.net" target="_blank"><?readfile("$docroot/webGui/images/UN-logotype-gradient.svg")?></a>',
|
||||
@@ -72,17 +79,14 @@ if (is_localhost() && !is_good_session()) {
|
||||
);
|
||||
}
|
||||
|
||||
private addModalsWebComponent(source: string): string {
|
||||
return source.replace('<body>', '<body>\n<unraid-modals></unraid-modals>');
|
||||
}
|
||||
private applyToSource(fileContent: string): string {
|
||||
const transformers = [
|
||||
this.removeNotificationBell.bind(this),
|
||||
this.replaceToasts.bind(this),
|
||||
this.addToaster.bind(this),
|
||||
this.patchGuiBootAuth.bind(this),
|
||||
this.hideHeaderLogo.bind(this),
|
||||
this.addModalsWebComponent.bind(this),
|
||||
this.hideHeaderLogo.bind(this),
|
||||
];
|
||||
|
||||
return transformers.reduce((content, transformer) => transformer(content), fileContent);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "unraid-monorepo",
|
||||
"private": true,
|
||||
"version": "4.9.0",
|
||||
"version": "4.9.3",
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"build:watch": " pnpm -r --parallel build:watch",
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
"lodash-es": "4.17.21",
|
||||
"nest-authz": "2.17.0",
|
||||
"rxjs": "7.8.2",
|
||||
"ws": "^8.18.0",
|
||||
"ws": "8.18.3",
|
||||
"zen-observable-ts": "1.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { access, constants, mkdir, readFile, rm } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ConfigType } from '../config/connect.config.js';
|
||||
import { ConnectStatusWriterService } from './connect-status-writer.service.js';
|
||||
|
||||
describe('ConnectStatusWriterService Config Behavior', () => {
|
||||
let service: ConnectStatusWriterService;
|
||||
let configService: ConfigService<ConfigType, true>;
|
||||
const testDir = '/tmp/connect-status-config-test';
|
||||
const testFilePath = join(testDir, 'connectStatus.json');
|
||||
|
||||
// Simulate config changes
|
||||
let configStore: any = {};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset config store
|
||||
configStore = {};
|
||||
|
||||
// Create test directory
|
||||
await mkdir(testDir, { recursive: true });
|
||||
|
||||
// Create a ConfigService mock that behaves like the real one
|
||||
configService = {
|
||||
get: vi.fn().mockImplementation((key: string) => {
|
||||
console.log(`ConfigService.get('${key}') called, returning:`, configStore[key]);
|
||||
return configStore[key];
|
||||
}),
|
||||
set: vi.fn().mockImplementation((key: string, value: any) => {
|
||||
console.log(`ConfigService.set('${key}', ${JSON.stringify(value)}) called`);
|
||||
configStore[key] = value;
|
||||
}),
|
||||
} as unknown as ConfigService<ConfigType, true>;
|
||||
|
||||
service = new ConnectStatusWriterService(configService);
|
||||
|
||||
// Override the status file path to use our test location
|
||||
Object.defineProperty(service, 'statusFilePath', {
|
||||
get: () => testFilePath,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await service.onModuleDestroy();
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should write status when config is updated directly', async () => {
|
||||
// Initialize service - should write PRE_INIT
|
||||
await service.onApplicationBootstrap();
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
let content = await readFile(testFilePath, 'utf-8');
|
||||
let data = JSON.parse(content);
|
||||
console.log('Initial status:', data);
|
||||
expect(data.connectionStatus).toBe('PRE_INIT');
|
||||
|
||||
// Update config directly (simulating what ConnectionService does)
|
||||
console.log('\n=== Updating config to CONNECTED ===');
|
||||
configService.set('connect.mothership', {
|
||||
status: 'CONNECTED',
|
||||
error: null,
|
||||
lastPing: Date.now(),
|
||||
});
|
||||
|
||||
// Call the writeStatus method directly (since @OnEvent handles the event)
|
||||
await service['writeStatus']();
|
||||
|
||||
content = await readFile(testFilePath, 'utf-8');
|
||||
data = JSON.parse(content);
|
||||
console.log('Status after config update:', data);
|
||||
expect(data.connectionStatus).toBe('CONNECTED');
|
||||
});
|
||||
|
||||
it('should test the actual flow with multiple status updates', async () => {
|
||||
await service.onApplicationBootstrap();
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
const statusUpdates = [
|
||||
{ status: 'CONNECTING', error: null, lastPing: null },
|
||||
{ status: 'CONNECTED', error: null, lastPing: Date.now() },
|
||||
{ status: 'DISCONNECTED', error: 'Lost connection', lastPing: Date.now() - 10000 },
|
||||
{ status: 'RECONNECTING', error: null, lastPing: Date.now() - 10000 },
|
||||
{ status: 'CONNECTED', error: null, lastPing: Date.now() },
|
||||
];
|
||||
|
||||
for (const update of statusUpdates) {
|
||||
console.log(`\n=== Updating to ${update.status} ===`);
|
||||
|
||||
// Update config
|
||||
configService.set('connect.mothership', update);
|
||||
|
||||
// Call writeStatus directly
|
||||
await service['writeStatus']();
|
||||
|
||||
const content = await readFile(testFilePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
console.log(`Status file shows: ${data.connectionStatus}`);
|
||||
expect(data.connectionStatus).toBe(update.status);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle case where config is not set before event', async () => {
|
||||
await service.onApplicationBootstrap();
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// Delete the config
|
||||
delete configStore['connect.mothership'];
|
||||
|
||||
// Call writeStatus without config
|
||||
console.log('\n=== Calling writeStatus with no config ===');
|
||||
await service['writeStatus']();
|
||||
|
||||
const content = await readFile(testFilePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
console.log('Status with no config:', data);
|
||||
expect(data.connectionStatus).toBe('PRE_INIT');
|
||||
|
||||
// Now set config and call writeStatus again
|
||||
console.log('\n=== Setting config and calling writeStatus ===');
|
||||
configService.set('connect.mothership', {
|
||||
status: 'CONNECTED',
|
||||
error: null,
|
||||
lastPing: Date.now(),
|
||||
});
|
||||
await service['writeStatus']();
|
||||
|
||||
const content2 = await readFile(testFilePath, 'utf-8');
|
||||
const data2 = JSON.parse(content2);
|
||||
console.log('Status after setting config:', data2);
|
||||
expect(data2.connectionStatus).toBe('CONNECTED');
|
||||
});
|
||||
|
||||
describe('cleanup on shutdown', () => {
|
||||
it('should delete status file on module destroy', async () => {
|
||||
await service.onApplicationBootstrap();
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// Verify file exists
|
||||
await expect(access(testFilePath, constants.F_OK)).resolves.not.toThrow();
|
||||
|
||||
// Cleanup
|
||||
await service.onModuleDestroy();
|
||||
|
||||
// Verify file is deleted
|
||||
await expect(access(testFilePath, constants.F_OK)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle cleanup when file does not exist', async () => {
|
||||
// Don't bootstrap (so no file is written)
|
||||
await expect(service.onModuleDestroy()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { access, constants, mkdir, readFile, rm } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ConfigType } from '../config/connect.config.js';
|
||||
import { ConnectStatusWriterService } from './connect-status-writer.service.js';
|
||||
|
||||
describe('ConnectStatusWriterService Integration', () => {
|
||||
let service: ConnectStatusWriterService;
|
||||
let configService: ConfigService<ConfigType, true>;
|
||||
const testDir = '/tmp/connect-status-test';
|
||||
const testFilePath = join(testDir, 'connectStatus.json');
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create test directory
|
||||
await mkdir(testDir, { recursive: true });
|
||||
|
||||
configService = {
|
||||
get: vi.fn().mockImplementation((key: string) => {
|
||||
console.log(`ConfigService.get called with key: ${key}`);
|
||||
return {
|
||||
status: 'CONNECTED',
|
||||
error: null,
|
||||
lastPing: Date.now(),
|
||||
};
|
||||
}),
|
||||
} as unknown as ConfigService<ConfigType, true>;
|
||||
|
||||
service = new ConnectStatusWriterService(configService);
|
||||
|
||||
// Override the status file path to use our test location
|
||||
Object.defineProperty(service, 'statusFilePath', {
|
||||
get: () => testFilePath,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await service.onModuleDestroy();
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should write initial PRE_INIT status, then update on event', async () => {
|
||||
// First, mock the config to return undefined (no connection metadata)
|
||||
vi.mocked(configService.get).mockReturnValue(undefined);
|
||||
|
||||
console.log('=== Starting onApplicationBootstrap ===');
|
||||
await service.onApplicationBootstrap();
|
||||
|
||||
// Wait a bit for the initial write to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// Read initial status
|
||||
const initialContent = await readFile(testFilePath, 'utf-8');
|
||||
const initialData = JSON.parse(initialContent);
|
||||
console.log('Initial status written:', initialData);
|
||||
|
||||
expect(initialData.connectionStatus).toBe('PRE_INIT');
|
||||
expect(initialData.error).toBeNull();
|
||||
expect(initialData.lastPing).toBeNull();
|
||||
|
||||
// Now update the mock to return CONNECTED status
|
||||
vi.mocked(configService.get).mockReturnValue({
|
||||
status: 'CONNECTED',
|
||||
error: null,
|
||||
lastPing: 1234567890,
|
||||
});
|
||||
|
||||
console.log('=== Calling writeStatus directly ===');
|
||||
await service['writeStatus']();
|
||||
|
||||
// Read updated status
|
||||
const updatedContent = await readFile(testFilePath, 'utf-8');
|
||||
const updatedData = JSON.parse(updatedContent);
|
||||
console.log('Updated status after writeStatus:', updatedData);
|
||||
|
||||
expect(updatedData.connectionStatus).toBe('CONNECTED');
|
||||
expect(updatedData.lastPing).toBe(1234567890);
|
||||
});
|
||||
|
||||
it('should handle rapid status changes correctly', async () => {
|
||||
const statusChanges = [
|
||||
{ status: 'PRE_INIT', error: null, lastPing: null },
|
||||
{ status: 'CONNECTING', error: null, lastPing: null },
|
||||
{ status: 'CONNECTED', error: null, lastPing: Date.now() },
|
||||
{ status: 'DISCONNECTED', error: 'Connection lost', lastPing: Date.now() - 5000 },
|
||||
{ status: 'CONNECTED', error: null, lastPing: Date.now() },
|
||||
];
|
||||
|
||||
let changeIndex = 0;
|
||||
vi.mocked(configService.get).mockImplementation(() => {
|
||||
const change = statusChanges[changeIndex];
|
||||
console.log(`Returning status ${changeIndex}: ${change.status}`);
|
||||
return change;
|
||||
});
|
||||
|
||||
await service.onApplicationBootstrap();
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// Simulate the final status change
|
||||
changeIndex = statusChanges.length - 1;
|
||||
console.log(`=== Calling writeStatus for final status: ${statusChanges[changeIndex].status} ===`);
|
||||
await service['writeStatus']();
|
||||
|
||||
// Read final status
|
||||
const finalContent = await readFile(testFilePath, 'utf-8');
|
||||
const finalData = JSON.parse(finalContent);
|
||||
console.log('Final status after status change:', finalData);
|
||||
|
||||
// Should have the last status
|
||||
expect(finalData.connectionStatus).toBe('CONNECTED');
|
||||
expect(finalData.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle multiple write calls correctly', async () => {
|
||||
const writes: number[] = [];
|
||||
const originalWriteStatus = service['writeStatus'].bind(service);
|
||||
|
||||
service['writeStatus'] = async function() {
|
||||
const timestamp = Date.now();
|
||||
writes.push(timestamp);
|
||||
console.log(`writeStatus called at ${timestamp}`);
|
||||
return originalWriteStatus();
|
||||
};
|
||||
|
||||
await service.onApplicationBootstrap();
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
const initialWrites = writes.length;
|
||||
console.log(`Initial writes: ${initialWrites}`);
|
||||
|
||||
// Make multiple write calls
|
||||
for (let i = 0; i < 3; i++) {
|
||||
console.log(`Calling writeStatus ${i}`);
|
||||
await service['writeStatus']();
|
||||
}
|
||||
|
||||
console.log(`Total writes: ${writes.length}`);
|
||||
console.log('Write timestamps:', writes);
|
||||
|
||||
// Should have initial write + 3 additional writes
|
||||
expect(writes.length).toBe(initialWrites + 3);
|
||||
});
|
||||
|
||||
describe('cleanup on shutdown', () => {
|
||||
it('should delete status file on module destroy', async () => {
|
||||
await service.onApplicationBootstrap();
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// Verify file exists
|
||||
await expect(access(testFilePath, constants.F_OK)).resolves.not.toThrow();
|
||||
|
||||
// Cleanup
|
||||
await service.onModuleDestroy();
|
||||
|
||||
// Verify file is deleted
|
||||
await expect(access(testFilePath, constants.F_OK)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle cleanup gracefully when file does not exist', async () => {
|
||||
// Don't bootstrap (so no file is created)
|
||||
await expect(service.onModuleDestroy()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { unlink, writeFile } from 'fs/promises';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ConfigType } from '../config/connect.config.js';
|
||||
import { ConnectStatusWriterService } from './connect-status-writer.service.js';
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
writeFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('ConnectStatusWriterService', () => {
|
||||
let service: ConnectStatusWriterService;
|
||||
let configService: ConfigService<ConfigType, true>;
|
||||
let writeFileMock: ReturnType<typeof vi.fn>;
|
||||
let unlinkMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
|
||||
writeFileMock = vi.mocked(writeFile);
|
||||
unlinkMock = vi.mocked(unlink);
|
||||
|
||||
configService = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
status: 'CONNECTED',
|
||||
error: null,
|
||||
lastPing: Date.now(),
|
||||
}),
|
||||
} as unknown as ConfigService<ConfigType, true>;
|
||||
|
||||
service = new ConnectStatusWriterService(configService);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('onApplicationBootstrap', () => {
|
||||
it('should write initial status on bootstrap', async () => {
|
||||
await service.onApplicationBootstrap();
|
||||
|
||||
expect(writeFileMock).toHaveBeenCalledTimes(1);
|
||||
expect(writeFileMock).toHaveBeenCalledWith(
|
||||
'/var/local/emhttp/connectStatus.json',
|
||||
expect.stringContaining('CONNECTED')
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle event-driven status changes', async () => {
|
||||
await service.onApplicationBootstrap();
|
||||
writeFileMock.mockClear();
|
||||
|
||||
// The service uses @OnEvent decorator, so we need to call the method directly
|
||||
await service['writeStatus']();
|
||||
|
||||
expect(writeFileMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('write content', () => {
|
||||
it('should write correct JSON structure with all fields', async () => {
|
||||
const mockMetadata = {
|
||||
status: 'CONNECTED',
|
||||
error: 'Some error',
|
||||
lastPing: 1234567890,
|
||||
};
|
||||
|
||||
vi.mocked(configService.get).mockReturnValue(mockMetadata);
|
||||
|
||||
await service.onApplicationBootstrap();
|
||||
|
||||
const writeCall = writeFileMock.mock.calls[0];
|
||||
const writtenData = JSON.parse(writeCall[1] as string);
|
||||
|
||||
expect(writtenData).toMatchObject({
|
||||
connectionStatus: 'CONNECTED',
|
||||
error: 'Some error',
|
||||
lastPing: 1234567890,
|
||||
allowedOrigins: '',
|
||||
});
|
||||
expect(writtenData.timestamp).toBeDefined();
|
||||
expect(typeof writtenData.timestamp).toBe('number');
|
||||
});
|
||||
|
||||
it('should handle missing connection metadata', async () => {
|
||||
vi.mocked(configService.get).mockReturnValue(undefined);
|
||||
|
||||
await service.onApplicationBootstrap();
|
||||
|
||||
const writeCall = writeFileMock.mock.calls[0];
|
||||
const writtenData = JSON.parse(writeCall[1] as string);
|
||||
|
||||
expect(writtenData).toMatchObject({
|
||||
connectionStatus: 'PRE_INIT',
|
||||
error: null,
|
||||
lastPing: null,
|
||||
allowedOrigins: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle write errors gracefully', async () => {
|
||||
writeFileMock.mockRejectedValue(new Error('Write failed'));
|
||||
|
||||
await expect(service.onApplicationBootstrap()).resolves.not.toThrow();
|
||||
|
||||
// Test direct write error handling
|
||||
await expect(service['writeStatus']()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup on shutdown', () => {
|
||||
it('should delete status file on module destroy', async () => {
|
||||
await service.onModuleDestroy();
|
||||
|
||||
expect(unlinkMock).toHaveBeenCalledTimes(1);
|
||||
expect(unlinkMock).toHaveBeenCalledWith('/var/local/emhttp/connectStatus.json');
|
||||
});
|
||||
|
||||
it('should handle file deletion errors gracefully', async () => {
|
||||
unlinkMock.mockRejectedValue(new Error('File not found'));
|
||||
|
||||
await expect(service.onModuleDestroy()).resolves.not.toThrow();
|
||||
|
||||
expect(unlinkMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should ensure file is deleted even if it was never written', async () => {
|
||||
// Don't bootstrap (so no file is written)
|
||||
await service.onModuleDestroy();
|
||||
|
||||
expect(unlinkMock).toHaveBeenCalledTimes(1);
|
||||
expect(unlinkMock).toHaveBeenCalledWith('/var/local/emhttp/connectStatus.json');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { unlink } from 'fs/promises';
|
||||
import { writeFile } from 'fs/promises';
|
||||
|
||||
import { ConfigType, ConnectionMetadata } from '../config/connect.config.js';
|
||||
import { EVENTS } from '../helper/nest-tokens.js';
|
||||
|
||||
@Injectable()
|
||||
export class ConnectStatusWriterService implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
constructor(private readonly configService: ConfigService<ConfigType, true>) {}
|
||||
|
||||
private logger = new Logger(ConnectStatusWriterService.name);
|
||||
|
||||
get statusFilePath() {
|
||||
// Write to /var/local/emhttp/connectStatus.json so PHP can read it
|
||||
return '/var/local/emhttp/connectStatus.json';
|
||||
}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
this.logger.verbose(`Status file path: ${this.statusFilePath}`);
|
||||
|
||||
// Write initial status
|
||||
await this.writeStatus();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
try {
|
||||
await unlink(this.statusFilePath);
|
||||
this.logger.verbose(`Status file deleted: ${this.statusFilePath}`);
|
||||
} catch (error) {
|
||||
this.logger.debug(`Could not delete status file: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent(EVENTS.MOTHERSHIP_CONNECTION_STATUS_CHANGED, { async: true })
|
||||
private async writeStatus() {
|
||||
try {
|
||||
const connectionMetadata = this.configService.get<ConnectionMetadata>('connect.mothership');
|
||||
|
||||
// Try to get allowed origins from the store
|
||||
let allowedOrigins = '';
|
||||
try {
|
||||
// We can't import from @app here, so we'll skip allowed origins for now
|
||||
// This can be added later if needed
|
||||
allowedOrigins = '';
|
||||
} catch (error) {
|
||||
this.logger.debug('Could not get allowed origins:', error);
|
||||
}
|
||||
|
||||
const statusData = {
|
||||
connectionStatus: connectionMetadata?.status || 'PRE_INIT',
|
||||
error: connectionMetadata?.error || null,
|
||||
lastPing: connectionMetadata?.lastPing || null,
|
||||
allowedOrigins: allowedOrigins,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const data = JSON.stringify(statusData, null, 2);
|
||||
this.logger.verbose(`Writing connection status: ${data}`);
|
||||
|
||||
await writeFile(this.statusFilePath, data);
|
||||
this.logger.verbose(`Status written to ${this.statusFilePath}`);
|
||||
} catch (error) {
|
||||
this.logger.error(error, `Error writing status to '${this.statusFilePath}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,20 @@ import { Module } from '@nestjs/common';
|
||||
import { ConnectApiKeyService } from '../authn/connect-api-key.service.js';
|
||||
import { CloudResolver } from '../connection-status/cloud.resolver.js';
|
||||
import { CloudService } from '../connection-status/cloud.service.js';
|
||||
import { ConnectStatusWriterService } from '../connection-status/connect-status-writer.service.js';
|
||||
import { TimeoutCheckerJob } from '../connection-status/timeout-checker.job.js';
|
||||
import { InternalClientService } from '../internal-rpc/internal.client.js';
|
||||
import { RemoteAccessModule } from '../remote-access/remote-access.module.js';
|
||||
import { MothershipConnectionService } from './connection.service.js';
|
||||
import { MothershipGraphqlClientService } from './graphql.client.js';
|
||||
import { MothershipSubscriptionHandler } from './mothership-subscription.handler.js';
|
||||
import { MothershipHandler } from './mothership.events.js';
|
||||
import { MothershipController } from './mothership.controller.js';
|
||||
import { MothershipHandler } from './mothership.events.js';
|
||||
|
||||
@Module({
|
||||
imports: [RemoteAccessModule],
|
||||
providers: [
|
||||
ConnectStatusWriterService,
|
||||
ConnectApiKeyService,
|
||||
MothershipConnectionService,
|
||||
MothershipGraphqlClientService,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/connect-plugin",
|
||||
"version": "4.9.0",
|
||||
"version": "4.9.3",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"commander": "14.0.0",
|
||||
|
||||
@@ -138,6 +138,34 @@ exit 0
|
||||
</INLINE>
|
||||
</FILE>
|
||||
|
||||
<FILE Run="/bin/bash" Method="install">
|
||||
<INLINE>
|
||||
<![CDATA[
|
||||
echo "Patching header logo if necessary..."
|
||||
|
||||
# We do this here instead of via API FileModification to avoid undesirable
|
||||
# rollback when the API is stopped.
|
||||
#
|
||||
# This is necessary on < 7.2 because the unraid-header-os-version web component
|
||||
# that ships with the base OS only displayes the version, not the logo as well.
|
||||
#
|
||||
# Rolling back in this case (i.e when stopping the API) yields a duplicate logo
|
||||
# that blocks interaction with the navigation menu.
|
||||
|
||||
# Remove the old header logo from DefaultPageLayout.php if present
|
||||
if [ -f "/usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php" ]; then
|
||||
sed -i 's|<a href="https://unraid.net" target="_blank"><?readfile("$docroot/webGui/images/UN-logotype-gradient.svg")?></a>||g' "/usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php"
|
||||
|
||||
# Add unraid-modals element if not already present
|
||||
if ! grep -q '<unraid-modals>' "/usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php"; then
|
||||
sed -i 's|<body>|<body>\n<unraid-modals></unraid-modals>|' "/usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php"
|
||||
fi
|
||||
fi
|
||||
|
||||
]]>
|
||||
</INLINE>
|
||||
</FILE>
|
||||
|
||||
<FILE Run="/bin/bash" Method="remove">
|
||||
<INLINE>
|
||||
MAINNAME="&name;"
|
||||
|
||||
@@ -23,9 +23,16 @@ $myservers_flash_cfg_path='/boot/config/plugins/dynamix.my.servers/myservers.cfg
|
||||
$myservers = file_exists($myservers_flash_cfg_path) ? @parse_ini_file($myservers_flash_cfg_path,true) : [];
|
||||
$isRegistered = !empty($myservers['remote']['username']);
|
||||
|
||||
$myservers_memory_cfg_path ='/var/local/emhttp/myservers.cfg';
|
||||
$mystatus = (file_exists($myservers_memory_cfg_path)) ? @parse_ini_file($myservers_memory_cfg_path) : [];
|
||||
$isConnected = (($mystatus['minigraph']??'')==='CONNECTED') ? true : false;
|
||||
// Read connection status from the new API status file
|
||||
$statusFilePath = '/var/local/emhttp/connectStatus.json';
|
||||
$connectionStatus = '';
|
||||
|
||||
if (file_exists($statusFilePath)) {
|
||||
$statusData = @json_decode(file_get_contents($statusFilePath), true);
|
||||
$connectionStatus = $statusData['connectionStatus'] ?? '';
|
||||
}
|
||||
|
||||
$isConnected = ($connectionStatus === 'CONNECTED') ? true : false;
|
||||
|
||||
$flashbackup_ini = '/var/local/emhttp/flashbackup.ini';
|
||||
|
||||
|
||||
@@ -168,9 +168,8 @@ class ServerState
|
||||
private function getMyServersCfgValues()
|
||||
{
|
||||
/**
|
||||
* @todo can we read this from somewhere other than the flash? Connect page uses this path and /boot/config/plugins/dynamix.my.servers/myservers.cfg…
|
||||
* - $myservers_memory_cfg_path ='/var/local/emhttp/myservers.cfg';
|
||||
* - $mystatus = (file_exists($myservers_memory_cfg_path)) ? @parse_ini_file($myservers_memory_cfg_path) : [];
|
||||
* Memory config is now written by the new API to /usr/local/emhttp/state/myservers.cfg
|
||||
* This contains runtime state including connection status.
|
||||
*/
|
||||
$flashCfgPath = '/boot/config/plugins/dynamix.my.servers/myservers.cfg';
|
||||
$this->myServersFlashCfg = file_exists($flashCfgPath) ? @parse_ini_file($flashCfgPath, true) : [];
|
||||
@@ -212,11 +211,19 @@ class ServerState
|
||||
* Include localhost in the test, but only display HTTP(S) URLs that do not include localhost.
|
||||
*/
|
||||
$this->host = $_SERVER['HTTP_HOST'] ?? "unknown";
|
||||
$memoryCfgPath = '/var/local/emhttp/myservers.cfg';
|
||||
$this->myServersMemoryCfg = (file_exists($memoryCfgPath)) ? @parse_ini_file($memoryCfgPath) : [];
|
||||
$this->myServersMiniGraphConnected = (($this->myServersMemoryCfg['minigraph'] ?? '') === 'CONNECTED');
|
||||
// Read connection status and allowed origins from the new API status file
|
||||
$statusFilePath = '/var/local/emhttp/connectStatus.json';
|
||||
$connectionStatus = '';
|
||||
$allowedOrigins = '';
|
||||
|
||||
if (file_exists($statusFilePath)) {
|
||||
$statusData = @json_decode(file_get_contents($statusFilePath), true);
|
||||
$connectionStatus = $statusData['connectionStatus'] ?? '';
|
||||
$allowedOrigins = $statusData['allowedOrigins'] ?? '';
|
||||
}
|
||||
|
||||
$this->myServersMiniGraphConnected = ($connectionStatus === 'CONNECTED');
|
||||
|
||||
$allowedOrigins = $this->myServersMemoryCfg['allowedOrigins'] ?? "";
|
||||
$extraOrigins = $this->myServersFlashCfg['api']['extraOrigins'] ?? "";
|
||||
$combinedOrigins = $allowedOrigins . "," . $extraOrigins; // combine the two strings for easier searching
|
||||
$combinedOrigins = str_replace(" ", "", $combinedOrigins); // replace any spaces with nothing
|
||||
|
||||
91
pnpm-lock.yaml
generated
91
pnpm-lock.yaml
generated
@@ -913,7 +913,7 @@ importers:
|
||||
version: 10.1.5(eslint@9.29.0(jiti@2.4.2))
|
||||
eslint-plugin-import:
|
||||
specifier: 2.31.0
|
||||
version: 2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.29.0(jiti@2.4.2))
|
||||
version: 2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.29.0(jiti@2.4.2))
|
||||
eslint-plugin-no-relative-import-paths:
|
||||
specifier: 1.6.1
|
||||
version: 1.6.1
|
||||
@@ -930,7 +930,7 @@ importers:
|
||||
specifier: 18.0.0
|
||||
version: 18.0.0
|
||||
jiti:
|
||||
specifier: ^2.4.2
|
||||
specifier: 2.4.2
|
||||
version: 2.4.2
|
||||
postcss:
|
||||
specifier: 8.5.6
|
||||
@@ -984,7 +984,7 @@ importers:
|
||||
specifier: 3.0.1
|
||||
version: 3.0.1(typescript@5.8.3)
|
||||
wrangler:
|
||||
specifier: ^3.87.0
|
||||
specifier: 3.114.10
|
||||
version: 3.114.10
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-linux-x64-gnu':
|
||||
@@ -13538,7 +13538,7 @@ snapshots:
|
||||
'@babel/traverse': 7.27.4
|
||||
'@babel/types': 7.27.6
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.1
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
gensync: 1.0.0-beta.2
|
||||
json5: 2.2.3
|
||||
semver: 6.3.1
|
||||
@@ -13879,7 +13879,7 @@ snapshots:
|
||||
'@babel/parser': 7.27.5
|
||||
'@babel/template': 7.27.2
|
||||
'@babel/types': 7.27.6
|
||||
debug: 4.4.1
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
globals: 11.12.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -14307,7 +14307,7 @@ snapshots:
|
||||
'@eslint/config-array@0.20.1':
|
||||
dependencies:
|
||||
'@eslint/object-schema': 2.1.6
|
||||
debug: 4.4.1
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
minimatch: 3.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -14352,7 +14352,7 @@ snapshots:
|
||||
'@eslint/eslintrc@3.3.1':
|
||||
dependencies:
|
||||
ajv: 6.12.6
|
||||
debug: 4.4.1
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
espree: 10.4.0
|
||||
globals: 14.0.0
|
||||
ignore: 5.3.2
|
||||
@@ -17065,7 +17065,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.34.1
|
||||
'@typescript-eslint/typescript-estree': 8.34.1(typescript@5.8.3)
|
||||
'@typescript-eslint/visitor-keys': 8.34.1
|
||||
debug: 4.4.1
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
eslint: 9.29.0(jiti@2.4.2)
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
@@ -17075,7 +17075,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.34.1(typescript@5.8.3)
|
||||
'@typescript-eslint/types': 8.34.1
|
||||
debug: 4.4.1
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -17093,7 +17093,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@typescript-eslint/typescript-estree': 8.34.1(typescript@5.8.3)
|
||||
'@typescript-eslint/utils': 8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
|
||||
debug: 4.4.1
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
eslint: 9.29.0(jiti@2.4.2)
|
||||
ts-api-utils: 2.1.0(typescript@5.8.3)
|
||||
typescript: 5.8.3
|
||||
@@ -17108,7 +17108,7 @@ snapshots:
|
||||
'@typescript-eslint/tsconfig-utils': 8.34.1(typescript@5.8.3)
|
||||
'@typescript-eslint/types': 8.34.1
|
||||
'@typescript-eslint/visitor-keys': 8.34.1
|
||||
debug: 4.4.1
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
fast-glob: 3.3.3
|
||||
is-glob: 4.0.3
|
||||
minimatch: 9.0.5
|
||||
@@ -17281,7 +17281,7 @@ snapshots:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
ast-v8-to-istanbul: 0.3.3
|
||||
debug: 4.4.1
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-lib-source-maps: 5.0.6
|
||||
@@ -19400,10 +19400,6 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
debug@4.4.1:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
debug@4.4.1(supports-color@5.5.0):
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -19969,7 +19965,7 @@ snapshots:
|
||||
|
||||
esbuild-register@3.6.0(esbuild@0.25.5):
|
||||
dependencies:
|
||||
debug: 4.4.1
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
esbuild: 0.25.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -20172,16 +20168,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.29.0(jiti@2.4.2)):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
|
||||
eslint: 9.29.0(jiti@2.4.2)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-es-x@7.8.0(eslint@9.29.0(jiti@2.4.2)):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.7.0(eslint@9.29.0(jiti@2.4.2))
|
||||
@@ -20236,35 +20222,6 @@ snapshots:
|
||||
- eslint-import-resolver-webpack
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.29.0(jiti@2.4.2)):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.8
|
||||
array.prototype.findlastindex: 1.2.5
|
||||
array.prototype.flat: 1.3.3
|
||||
array.prototype.flatmap: 1.3.3
|
||||
debug: 3.2.7
|
||||
doctrine: 2.1.0
|
||||
eslint: 9.29.0(jiti@2.4.2)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.29.0(jiti@2.4.2))
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
minimatch: 3.1.2
|
||||
object.fromentries: 2.0.8
|
||||
object.groupby: 1.0.3
|
||||
object.values: 1.2.1
|
||||
semver: 6.3.1
|
||||
string.prototype.trimend: 1.0.9
|
||||
tsconfig-paths: 3.15.0
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
|
||||
transitivePeerDependencies:
|
||||
- eslint-import-resolver-typescript
|
||||
- eslint-import-resolver-webpack
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-jsdoc@50.8.0(eslint@9.29.0(jiti@2.4.2)):
|
||||
dependencies:
|
||||
'@es-joy/jsdoccomment': 0.50.2
|
||||
@@ -20406,7 +20363,7 @@ snapshots:
|
||||
ajv: 6.12.6
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.1
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 8.4.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
@@ -21508,7 +21465,7 @@ snapshots:
|
||||
http-proxy-agent@7.0.2:
|
||||
dependencies:
|
||||
agent-base: 7.1.3
|
||||
debug: 4.4.1
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -21555,7 +21512,7 @@ snapshots:
|
||||
https-proxy-agent@7.0.6:
|
||||
dependencies:
|
||||
agent-base: 7.1.3
|
||||
debug: 4.4.1
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -22039,7 +21996,7 @@ snapshots:
|
||||
istanbul-lib-source-maps@5.0.6:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.25
|
||||
debug: 4.4.1
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -24057,7 +24014,7 @@ snapshots:
|
||||
|
||||
postcss-styl@0.12.3:
|
||||
dependencies:
|
||||
debug: 4.4.1
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
fast-diff: 1.3.0
|
||||
lodash.sortedlastindex: 4.1.0
|
||||
postcss: 8.5.6
|
||||
@@ -25434,7 +25391,7 @@ snapshots:
|
||||
stylus@0.57.0:
|
||||
dependencies:
|
||||
css: 3.0.0
|
||||
debug: 4.4.1
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
glob: 7.2.3
|
||||
safer-buffer: 2.1.2
|
||||
sax: 1.2.4
|
||||
@@ -26255,7 +26212,7 @@ snapshots:
|
||||
vite-node@3.2.4(@types/node@22.15.32)(jiti@2.4.2)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.1
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 2.0.3
|
||||
vite: 7.0.3(@types/node@22.15.32)(jiti@2.4.2)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)
|
||||
@@ -26297,7 +26254,7 @@ snapshots:
|
||||
'@microsoft/api-extractor': 7.43.0(@types/node@22.15.32)
|
||||
'@rollup/pluginutils': 5.2.0(rollup@4.44.0)
|
||||
'@vue/language-core': 1.8.27(typescript@5.8.3)
|
||||
debug: 4.4.1
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
kolorist: 1.8.0
|
||||
magic-string: 0.30.17
|
||||
typescript: 5.8.3
|
||||
@@ -26313,7 +26270,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@antfu/utils': 0.7.10
|
||||
'@rollup/pluginutils': 5.2.0(rollup@4.44.0)
|
||||
debug: 4.4.1
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
error-stack-parser-es: 0.1.5
|
||||
fs-extra: 11.3.0
|
||||
open: 10.1.2
|
||||
@@ -26549,7 +26506,7 @@ snapshots:
|
||||
'@vitest/spy': 3.2.4
|
||||
'@vitest/utils': 3.2.4
|
||||
chai: 5.2.0
|
||||
debug: 4.4.1
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
expect-type: 1.2.1
|
||||
magic-string: 0.30.17
|
||||
pathe: 2.0.3
|
||||
@@ -26634,7 +26591,7 @@ snapshots:
|
||||
|
||||
vue-eslint-parser@10.1.3(eslint@9.29.0(jiti@2.4.2)):
|
||||
dependencies:
|
||||
debug: 4.4.1
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
eslint: 9.29.0(jiti@2.4.2)
|
||||
eslint-scope: 8.4.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/ui",
|
||||
"version": "4.9.0",
|
||||
"version": "4.9.3",
|
||||
"private": true,
|
||||
"license": "GPL-2.0-or-later",
|
||||
"type": "module",
|
||||
@@ -94,7 +94,7 @@
|
||||
"eslint-plugin-storybook": "9.0.16",
|
||||
"eslint-plugin-vue": "10.2.0",
|
||||
"happy-dom": "18.0.0",
|
||||
"jiti": "^2.4.2",
|
||||
"jiti": "2.4.2",
|
||||
"postcss": "8.5.6",
|
||||
"postcss-import": "16.1.1",
|
||||
"prettier": "3.5.3",
|
||||
@@ -112,7 +112,7 @@
|
||||
"vitest": "3.2.4",
|
||||
"vue": "3.5.17",
|
||||
"vue-tsc": "3.0.1",
|
||||
"wrangler": "^3.87.0"
|
||||
"wrangler": "3.114.10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-gnu": "4.44.0"
|
||||
|
||||
@@ -38,6 +38,11 @@ vi.mock('@vue/apollo-composable', () => ({
|
||||
onResult: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
}),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
onDone: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
}),
|
||||
provideApolloClient: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -41,6 +41,11 @@ vi.mock('@vue/apollo-composable', () => ({
|
||||
onResult: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
}),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
onDone: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
}),
|
||||
provideApolloClient: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, type Component } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import type { Component } from 'vue';
|
||||
import { BellIcon, ExclamationTriangleIcon, ShieldExclamationIcon } from '@heroicons/vue/24/solid';
|
||||
import { cn } from '@unraid/ui';
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, type Component } from 'vue';
|
||||
import { computed, reactive } from 'vue';
|
||||
import type { Component } from 'vue';
|
||||
import { computedAsync } from '@vueuse/core';
|
||||
import { Markdown } from '@/helpers/markdown';
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { InMemoryCache, type InMemoryCacheConfig } from '@apollo/client/core';
|
||||
import { InMemoryCache } from '@apollo/client/core';
|
||||
import type { InMemoryCacheConfig } from '@apollo/client/core';
|
||||
|
||||
import type { NotificationOverview } from '~/composables/gql/graphql';
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
import { Marked, type MarkedExtension } from 'marked';
|
||||
import { Marked } from 'marked';
|
||||
import type { MarkedExtension } from 'marked';
|
||||
|
||||
const defaultMarkedExtension: MarkedExtension = {
|
||||
hooks: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/web",
|
||||
"version": "4.9.0",
|
||||
"version": "4.9.3",
|
||||
"private": true,
|
||||
"license": "GPL-2.0-or-later",
|
||||
"scripts": {
|
||||
|
||||
@@ -61,6 +61,59 @@ export const useAccountStore = defineStore('account', () => {
|
||||
accountActionStatus.value = 'waiting';
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize mutations during store setup to maintain Apollo context
|
||||
const { mutate: signOutMutation, onDone: onSignOutDone, onError: onSignOutError } = useMutation(CONNECT_SIGN_OUT);
|
||||
const { mutate: signInMutation, onDone: onSignInDone, onError: onSignInError } = useMutation(CONNECT_SIGN_IN);
|
||||
|
||||
// Handle sign out mutation results
|
||||
onSignOutDone((res) => {
|
||||
console.debug('[connectSignOutMutation]', res);
|
||||
accountActionStatus.value = 'success';
|
||||
setQueueConnectSignOut(false); // reset
|
||||
});
|
||||
|
||||
onSignOutError((error) => {
|
||||
logErrorMessages(error);
|
||||
accountActionStatus.value = 'failed';
|
||||
errorsStore.setError({
|
||||
heading: 'Failed to update Connect account configuration',
|
||||
message: error.message,
|
||||
level: 'error',
|
||||
ref: 'connectSignOutMutation',
|
||||
type: 'account',
|
||||
});
|
||||
});
|
||||
|
||||
// Handle sign in mutation results
|
||||
onSignInDone((res) => {
|
||||
if (res.data?.connectSignIn) {
|
||||
accountActionStatus.value = 'success';
|
||||
setConnectSignInPayload(undefined); // reset
|
||||
return;
|
||||
}
|
||||
accountActionStatus.value = 'failed';
|
||||
errorsStore.setError({
|
||||
heading: 'unraid-api failed to update Connect account configuration',
|
||||
message: 'Sign In mutation unsuccessful',
|
||||
level: 'error',
|
||||
ref: 'connectSignInMutation',
|
||||
type: 'account',
|
||||
});
|
||||
});
|
||||
|
||||
onSignInError((error) => {
|
||||
logErrorMessages(error);
|
||||
accountActionStatus.value = 'failed';
|
||||
errorsStore.setError({
|
||||
heading: 'unraid-api failed to update Connect account configuration',
|
||||
message: error.message,
|
||||
level: 'error',
|
||||
ref: 'connectSignInMutation',
|
||||
type: 'account',
|
||||
});
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
if (unraidApiClient.value && connectSignInPayload.value) {
|
||||
// connectSignInMutation();
|
||||
@@ -258,7 +311,7 @@ export const useAccountStore = defineStore('account', () => {
|
||||
);
|
||||
};
|
||||
|
||||
const connectSignInMutation = async () => {
|
||||
const connectSignInMutation = () => {
|
||||
if (
|
||||
!connectSignInPayload.value ||
|
||||
(connectSignInPayload.value &&
|
||||
@@ -271,83 +324,21 @@ export const useAccountStore = defineStore('account', () => {
|
||||
}
|
||||
|
||||
accountActionStatus.value = 'updating';
|
||||
const {
|
||||
mutate: signInMutation,
|
||||
onDone,
|
||||
onError,
|
||||
} = await useMutation(CONNECT_SIGN_IN, {
|
||||
variables: {
|
||||
input: {
|
||||
apiKey: connectSignInPayload.value.apiKey,
|
||||
userInfo: {
|
||||
email: connectSignInPayload.value.email,
|
||||
preferred_username: connectSignInPayload.value.preferred_username,
|
||||
},
|
||||
|
||||
return signInMutation({
|
||||
input: {
|
||||
apiKey: connectSignInPayload.value.apiKey,
|
||||
userInfo: {
|
||||
email: connectSignInPayload.value.email,
|
||||
preferred_username: connectSignInPayload.value.preferred_username,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
signInMutation();
|
||||
|
||||
onDone((res) => {
|
||||
if (res.data?.connectSignIn) {
|
||||
accountActionStatus.value = 'success';
|
||||
setConnectSignInPayload(undefined); // reset
|
||||
return;
|
||||
}
|
||||
accountActionStatus.value = 'failed';
|
||||
errorsStore.setError({
|
||||
heading: 'unraid-api failed to update Connect account configuration',
|
||||
message: 'Sign In mutation unsuccessful',
|
||||
level: 'error',
|
||||
ref: 'connectSignInMutation',
|
||||
type: 'account',
|
||||
});
|
||||
});
|
||||
|
||||
onError((error) => {
|
||||
logErrorMessages(error);
|
||||
accountActionStatus.value = 'failed';
|
||||
errorsStore.setError({
|
||||
heading: 'unraid-api failed to update Connect account configuration',
|
||||
message: error.message,
|
||||
level: 'error',
|
||||
ref: 'connectSignInMutation',
|
||||
type: 'account',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const connectSignOutMutation = async () => {
|
||||
const connectSignOutMutation = () => {
|
||||
accountActionStatus.value = 'updating';
|
||||
// @todo is this still needed here with the change to a mutation?
|
||||
// if (!serverStore.registered && accountAction.value && !accountAction.value?.user) {
|
||||
// accountActionHide.value = true;
|
||||
// accountActionStatus.value = 'success';
|
||||
// return;
|
||||
// }
|
||||
|
||||
const { mutate: signOutMutation, onDone, onError } = await useMutation(CONNECT_SIGN_OUT);
|
||||
|
||||
signOutMutation();
|
||||
|
||||
onDone((res) => {
|
||||
console.debug('[connectSignOutMutation]', res);
|
||||
accountActionStatus.value = 'success';
|
||||
setQueueConnectSignOut(false); // reset
|
||||
});
|
||||
|
||||
onError((error) => {
|
||||
logErrorMessages(error);
|
||||
accountActionStatus.value = 'failed';
|
||||
errorsStore.setError({
|
||||
heading: 'Failed to update Connect account configuration',
|
||||
message: error.message,
|
||||
level: 'error',
|
||||
ref: 'connectSignOutMutation',
|
||||
type: 'account',
|
||||
});
|
||||
});
|
||||
return signOutMutation();
|
||||
};
|
||||
|
||||
const setAccountAction = (action: ExternalSignIn | ExternalSignOut) => {
|
||||
|
||||
Reference in New Issue
Block a user