Compare commits

...

14 Commits

Author SHA1 Message Date
renovate[bot]
e323f2a613 Merge 1f49023571 into 33e0b1ab24 2025-07-09 17:35:38 +00:00
renovate[bot]
1f49023571 chore(deps): pin dependencies 2025-07-09 17:35:34 +00:00
Pujit Mehrotra
33e0b1ab24 fix: backport <unraid-modals> upon plg install when necessary (#1499)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Prevented duplicate insertion of the modal component in the page
layout.

* **Chores**
* Improved installation script to ensure the modal component is added
only if missing.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-09 13:32:55 -04:00
Eli Bosley
ca4e2db1f2 fix: event emitter setup for writing status (#1496)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Updated permissions to allow additional Bash command patterns in the
configuration.
* Improved connection status updates by triggering them via event
listeners during application bootstrap.
* Adjusted module provider registrations to reflect service relocation
within the application structure.
* **Tests**
* Added comprehensive unit and integration tests for connection status
writing and cleanup behaviors.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-09 13:16:53 -04:00
Pujit Mehrotra
ea20d1e211 fix: DefaultPageLayout patch rollback omits legacy header logo (#1497)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Enhanced the header by displaying the OS version and additional server
information.
* Introduced a new notification system using a modern UI component for
toasts.
* Automatically creates a root session for local requests when no valid
session exists.

* **Bug Fixes**
* Removed outdated pop-up notification logic and bell icon from the
navigation area.

* **Style**
* Updated header layout and improved formatting for a cleaner
appearance.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-09 13:12:18 -04:00
github-actions[bot]
79c57b8ed0 chore(main): release 4.9.3 (#1495)
🤖 I have created a release *beep* *boop*
---


## [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](4168f43e3e))

---
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-07-09 11:06:47 -04:00
Pujit Mehrotra
4168f43e3e fix: duplicated header logo after api stops (#1493)
Move legacy header logo omission from API file modifier to plg install
step to avoid breaking rollback (ie upon `unraid-api stop`) due to web
component upgrade.

Tested on 7.1.4 & 7.2.0-beta.0.16

Testing & Reproduction procedure:

1. Install connect plugin
2. Run `unraid-api stop` on server
3. Refresh page. Before Unraid 7.2, Plugin versions prior to this will
display a duplicated logo that blocks the nav menu. Now, it will not.
Plugin uninstall behavior remains unchanged.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* The plugin installation process now updates the header logo by
removing the old logo from the interface during installation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-09 10:59:10 -04:00
github-actions[bot]
20de3ec8d6 chore(main): release 4.9.2 (#1488)
🤖 I have created a release *beep* *boop*
---


## [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](6bf3f77638))
* invalid state for unraid plugin
([#1492](https://github.com/unraid/api/issues/1492))
([39b8f45](39b8f453da))
* release note escaping
([5b6bcb6](5b6bcb6043))

---
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-07-09 09:23:25 -04:00
Eli Bosley
39b8f453da fix: invalid state for unraid plugin (#1492)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Improved connection status handling by introducing a new service that
writes connection status to a JSON file for enhanced integration.
* Updated system components to read connection status and allowed
origins from the new JSON file, ensuring more reliable and up-to-date
information.

* **Chores**
* Expanded allowed Bash command permissions to include commands starting
with "mv:".
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-09 09:21:43 -04:00
Eli Bosley
6bf3f77638 fix: invalid configs no longer crash API (#1491)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved error handling when loading and parsing configuration files,
preventing crashes and ensuring fallback to default settings if issues
occur.
* Enhanced logging for configuration errors, including warnings for
empty files and detailed error messages for JSON parsing failures.
* Added error handling to plugin listing to avoid failures when
configuration loading encounters errors.

* **Chores**
* Updated permissions to allow linting only for the `./api` package
using a filtered command.

* **Tests**
* Added comprehensive tests for configuration loading, parsing,
persistence, and updating, covering various file states and error
scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-09 08:50:43 -04:00
Eli Bosley
a79d049865 chore: lint for renovate PR (#1481) 2025-07-08 17:01:49 -04:00
Eli Bosley
5b6bcb6043 fix: release note escaping 2025-07-08 16:41:05 -04:00
github-actions[bot]
6ee3cae962 chore(main): release 4.9.1 (#1487)
🤖 I have created a release *beep* *boop*
---


## [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](862b54de8c))
* sign out doesn't work
([#1486](https://github.com/unraid/api/issues/1486))
([f3671c3](f3671c3e07))

---
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-07-08 16:30:00 -04:00
Eli Bosley
f3671c3e07 fix: sign out doesn't work (#1486)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Refactor**
* Improved handling of Connect account sign-in and sign-out with
persistent mutation instances for better status updates and error
reporting.

* **Chores**
* Expanded allowed command patterns in configuration for development,
build, and testing tasks.

* **Tests**
* Enhanced mutation mocks in component tests to increase test
reliability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-08 16:28:38 -04:00
35 changed files with 1416 additions and 221 deletions

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
{".":"4.9.0"}
{".":"4.9.3"}

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/web",
"version": "4.9.0",
"version": "4.9.3",
"private": true,
"license": "GPL-2.0-or-later",
"scripts": {

View File

@@ -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) => {