diff --git a/api/src/store/index.ts b/api/src/store/index.ts index 61542dbd06..3b5ca9418f 100644 --- a/api/src/store/index.ts +++ b/api/src/store/index.ts @@ -48,7 +48,7 @@ export const loadDynamixConfig = () => { }; export const getters = { - dynamix: () => loadDynamixConfig(), + dynamix: () => store.getState().dynamix, emhttp: () => store.getState().emhttp, paths: () => store.getState().paths, registration: () => store.getState().registration, diff --git a/api/src/unraid-api/config/dynamix-config-refresh.service.spec.ts b/api/src/unraid-api/config/dynamix-config-refresh.service.spec.ts new file mode 100644 index 0000000000..fe13bc3420 --- /dev/null +++ b/api/src/unraid-api/config/dynamix-config-refresh.service.spec.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FileLoadStatus } from '@app/store/types.js'; +import { DynamixConfigRefreshService } from '@app/unraid-api/config/dynamix-config-refresh.service.js'; + +const { dispatch, getState, loadDynamixConfigFromDiskSync, updateDynamixConfig } = vi.hoisted(() => ({ + dispatch: vi.fn(), + getState: vi.fn(), + loadDynamixConfigFromDiskSync: vi.fn(), + updateDynamixConfig: vi.fn((payload: unknown) => ({ type: 'dynamix/update', payload })), +})); + +vi.mock('@app/store/index.js', () => ({ + store: { + dispatch, + getState, + }, +})); + +vi.mock('@app/store/actions/load-dynamix-config-file.js', () => ({ + loadDynamixConfigFromDiskSync, +})); + +vi.mock('@app/store/modules/dynamix.js', () => ({ + updateDynamixConfig, +})); + +describe('DynamixConfigRefreshService', () => { + let service: DynamixConfigRefreshService; + + beforeEach(() => { + vi.useFakeTimers(); + dispatch.mockReset(); + getState.mockReset(); + loadDynamixConfigFromDiskSync.mockReset(); + updateDynamixConfig.mockClear(); + + getState.mockReturnValue({ + paths: { + 'dynamix-config': ['/etc/default.cfg', '/boot/config/plugins/dynamix/dynamix.cfg'], + }, + }); + + service = new DynamixConfigRefreshService(); + }); + + afterEach(() => { + service.onModuleDestroy(); + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it('loads on init and dispatches loaded status', () => { + loadDynamixConfigFromDiskSync.mockReturnValue({ notify: { path: '/tmp/notifications' } }); + + service.onModuleInit(); + + expect(loadDynamixConfigFromDiskSync).toHaveBeenCalledWith([ + '/etc/default.cfg', + '/boot/config/plugins/dynamix/dynamix.cfg', + ]); + expect(updateDynamixConfig).toHaveBeenCalledWith({ + notify: { path: '/tmp/notifications' }, + status: FileLoadStatus.LOADED, + }); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it('skips dispatch when loaded config is unchanged', () => { + loadDynamixConfigFromDiskSync.mockReturnValue({ notify: { path: '/tmp/notifications' } }); + + service.onModuleInit(); + vi.advanceTimersByTime(5000); + + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it('dispatches failed status when loading throws', () => { + loadDynamixConfigFromDiskSync.mockImplementation(() => { + throw new Error('boom'); + }); + + service.onModuleInit(); + + expect(updateDynamixConfig).toHaveBeenCalledWith({ status: FileLoadStatus.FAILED_LOADING }); + expect(dispatch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/api/src/unraid-api/config/dynamix-config-refresh.service.ts b/api/src/unraid-api/config/dynamix-config-refresh.service.ts new file mode 100644 index 0000000000..a288e2f6f3 --- /dev/null +++ b/api/src/unraid-api/config/dynamix-config-refresh.service.ts @@ -0,0 +1,58 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; + +import { loadDynamixConfigFromDiskSync } from '@app/store/actions/load-dynamix-config-file.js'; +import { store } from '@app/store/index.js'; +import { updateDynamixConfig } from '@app/store/modules/dynamix.js'; +import { FileLoadStatus } from '@app/store/types.js'; + +const DYNAMIX_REFRESH_INTERVAL_MS = 5000; + +@Injectable() +export class DynamixConfigRefreshService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(DynamixConfigRefreshService.name); + private refreshTimer: NodeJS.Timeout | null = null; + private lastSerializedConfig: string | null = null; + + onModuleInit() { + this.refresh(); + this.refreshTimer = setInterval(() => { + this.refresh(); + }, DYNAMIX_REFRESH_INTERVAL_MS); + } + + onModuleDestroy() { + if (!this.refreshTimer) { + return; + } + + clearInterval(this.refreshTimer); + this.refreshTimer = null; + } + + private refresh() { + const configPaths = store.getState().paths['dynamix-config'] ?? []; + + try { + const config = loadDynamixConfigFromDiskSync(configPaths); + const serializedConfig = JSON.stringify(config); + if (serializedConfig === this.lastSerializedConfig) { + return; + } + + store.dispatch( + updateDynamixConfig({ + ...config, + status: FileLoadStatus.LOADED, + }) + ); + this.lastSerializedConfig = serializedConfig; + } catch (error) { + this.logger.error(error, 'Failed to refresh dynamix config from disk'); + store.dispatch( + updateDynamixConfig({ + status: FileLoadStatus.FAILED_LOADING, + }) + ); + } + } +} diff --git a/api/src/unraid-api/config/legacy-config.module.ts b/api/src/unraid-api/config/legacy-config.module.ts index c0d851b4ea..d338a4ff0f 100644 --- a/api/src/unraid-api/config/legacy-config.module.ts +++ b/api/src/unraid-api/config/legacy-config.module.ts @@ -5,6 +5,7 @@ import { ConfigModule } from '@nestjs/config'; import { apiConfig } from '@app/unraid-api/config/api-config.module.js'; import { loadAppEnvironment, loadLegacyStore } from '@app/unraid-api/config/config.loader.js'; +import { DynamixConfigRefreshService } from '@app/unraid-api/config/dynamix-config-refresh.service.js'; import { StoreSyncService } from '@app/unraid-api/config/store-sync.service.js'; @Module({ @@ -14,7 +15,7 @@ import { StoreSyncService } from '@app/unraid-api/config/store-sync.service.js'; load: [loadAppEnvironment, loadLegacyStore, apiConfig], }), ], - providers: [StoreSyncService], - exports: [StoreSyncService], + providers: [StoreSyncService, DynamixConfigRefreshService], + exports: [StoreSyncService, DynamixConfigRefreshService], }) export class LegacyConfigModule {}