From 621f9064dfddd25ca657b9bf929d11e20a84c6b7 Mon Sep 17 00:00:00 2001 From: Eli Bosley <11823237+elibosley@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:13:43 -0500 Subject: [PATCH] feat(api): add centralized dynamix config refresh service --- api/src/store/index.ts | 2 +- .../dynamix-config-refresh.service.spec.ts | 88 +++++++++++++++++++ .../config/dynamix-config-refresh.service.ts | 58 ++++++++++++ .../unraid-api/config/legacy-config.module.ts | 5 +- .../config/store-sync.service.spec.ts | 87 ++++++++++++++++++ .../unraid-api/config/store-sync.service.ts | 31 ++++++- .../loadNotificationsFile.test.ts | 19 +++- .../notifications.service.spec.ts | 35 ++++---- .../notifications/notifications.service.ts | 11 ++- api/src/unraid-api/plugin/plugin.service.ts | 3 +- 10 files changed, 311 insertions(+), 28 deletions(-) create mode 100644 api/src/unraid-api/config/dynamix-config-refresh.service.spec.ts create mode 100644 api/src/unraid-api/config/dynamix-config-refresh.service.ts create mode 100644 api/src/unraid-api/config/store-sync.service.spec.ts 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 {} diff --git a/api/src/unraid-api/config/store-sync.service.spec.ts b/api/src/unraid-api/config/store-sync.service.spec.ts new file mode 100644 index 0000000000..c8b6f577d5 --- /dev/null +++ b/api/src/unraid-api/config/store-sync.service.spec.ts @@ -0,0 +1,87 @@ +import { ConfigService } from '@nestjs/config'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { StoreSyncService } from '@app/unraid-api/config/store-sync.service.js'; + +const { subscribe, getState } = vi.hoisted(() => ({ + subscribe: vi.fn(), + getState: vi.fn(), +})); + +vi.mock('@app/store/index.js', () => ({ + store: { + subscribe, + getState, + }, +})); + +describe('StoreSyncService', () => { + let service: StoreSyncService; + let configService: ConfigService; + let setSpy: ReturnType; + let unsubscribe: ReturnType; + let subscriber: (() => void) | undefined; + + beforeEach(() => { + vi.useFakeTimers(); + subscribe.mockReset(); + getState.mockReset(); + + unsubscribe = vi.fn(); + subscriber = undefined; + + subscribe.mockImplementation((callback: () => void) => { + subscriber = callback; + return unsubscribe; + }); + + configService = new ConfigService(); + setSpy = vi.spyOn(configService, 'set'); + + service = new StoreSyncService(configService); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it('debounces sync operations and writes once after rapid updates', () => { + getState.mockReturnValue({ count: 2 }); + + subscriber?.(); + vi.advanceTimersByTime(500); + subscriber?.(); + + expect(setSpy).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1000); + + expect(setSpy).toHaveBeenCalledTimes(1); + expect(setSpy).toHaveBeenCalledWith('store', { count: 2 }); + }); + + it('skips writes when serialized state is unchanged', () => { + getState.mockReturnValue({ count: 1 }); + + subscriber?.(); + vi.advanceTimersByTime(1000); + + subscriber?.(); + vi.advanceTimersByTime(1000); + + expect(setSpy).toHaveBeenCalledTimes(1); + }); + + it('unsubscribes and clears timer on module destroy', () => { + getState.mockReturnValue({ count: 1 }); + + subscriber?.(); + service.onModuleDestroy(); + vi.advanceTimersByTime(1000); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(setSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/api/src/unraid-api/config/store-sync.service.ts b/api/src/unraid-api/config/store-sync.service.ts index afc168c6b1..bd77cc6195 100644 --- a/api/src/unraid-api/config/store-sync.service.ts +++ b/api/src/unraid-api/config/store-sync.service.ts @@ -5,19 +5,46 @@ import type { Unsubscribe } from '@reduxjs/toolkit'; import { store } from '@app/store/index.js'; +const STORE_SYNC_DEBOUNCE_MS = 1000; + @Injectable() export class StoreSyncService implements OnModuleDestroy { private unsubscribe: Unsubscribe; private logger = new Logger(StoreSyncService.name); + private syncTimer: NodeJS.Timeout | null = null; + private lastSyncedState: string | null = null; constructor(private configService: ConfigService) { this.unsubscribe = store.subscribe(() => { - this.configService.set('store', store.getState()); - this.logger.verbose('Synced store to NestJS Config'); + this.scheduleSync(); }); } + private scheduleSync() { + if (this.syncTimer) { + clearTimeout(this.syncTimer); + } + + this.syncTimer = setTimeout(() => { + this.syncTimer = null; + const state = store.getState(); + const serializedState = JSON.stringify(state); + if (serializedState === this.lastSyncedState) { + return; + } + + this.configService.set('store', state); + this.lastSyncedState = serializedState; + this.logger.verbose('Synced store to NestJS Config'); + }, STORE_SYNC_DEBOUNCE_MS); + } + onModuleDestroy() { + if (this.syncTimer) { + clearTimeout(this.syncTimer); + this.syncTimer = null; + } + this.unsubscribe(); } } diff --git a/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts b/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts index 1c582ddd33..81f675a217 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts @@ -1,5 +1,6 @@ // Unit Test File for NotificationsService: loadNotificationFile +import { ConfigService } from '@nestjs/config'; import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; @@ -26,7 +27,6 @@ vi.mock('fs/promises', async () => { // Mock getters.dynamix, Logger, and pubsub vi.mock('@app/store/index.js', () => { - // Create test directory path inside factory function const testNotificationsDir = join(tmpdir(), 'unraid-api-test-notifications'); return { @@ -69,6 +69,19 @@ vi.mock('@nestjs/common', async (importOriginal) => { // Create a temporary test directory path for use in integration tests const testNotificationsDir = join(tmpdir(), 'unraid-api-test-notifications'); +const createNotificationsService = (notificationPath = testNotificationsDir) => { + const configService = new ConfigService({ + store: { + dynamix: { + notify: { + path: notificationPath, + }, + }, + }, + }); + return new NotificationsService(configService); +}; + describe('NotificationsService - loadNotificationFile (minimal mocks)', () => { let service: NotificationsService; let mockReadFile: any; @@ -77,7 +90,7 @@ describe('NotificationsService - loadNotificationFile (minimal mocks)', () => { const fsPromises = await import('fs/promises'); mockReadFile = fsPromises.readFile as any; vi.mocked(mockReadFile).mockClear(); - service = new NotificationsService(); + service = createNotificationsService(); }); it('should load and validate a valid notification file', async () => { @@ -247,7 +260,7 @@ describe('NotificationsService - deleteNotification (integration test)', () => { mkdirSync(join(testNotificationsDir, 'unread'), { recursive: true }); mkdirSync(join(testNotificationsDir, 'archive'), { recursive: true }); - service = new NotificationsService(); + service = createNotificationsService(); }); afterEach(() => { diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.spec.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.spec.ts index 8014821982..260a6dbea2 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.spec.ts @@ -7,6 +7,7 @@ // archiving, unarchiving, deletion, and legacy CLI compatibility. import type { TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; import { Test } from '@nestjs/testing'; import { existsSync } from 'fs'; import { mkdir } from 'fs/promises'; @@ -48,22 +49,16 @@ describe.sequential('NotificationsService', () => { beforeAll(async () => { await mkdir(basePath, { recursive: true }); - // need to mock the dynamix import bc the file watcher is init'ed in the service constructor - // i.e. before we can mock service.paths() - vi.mock(import('../../../../store/index.js'), async (importOriginal) => { - const mod = await importOriginal(); - return { - ...mod, - getters: { - dynamix: () => ({ - notify: { path: basePath }, - }), - }, - } as typeof mod; - }); - const module: TestingModule = await Test.createTestingModule({ - providers: [NotificationsService], + providers: [ + NotificationsService, + { + provide: ConfigService, + useValue: { + get: vi.fn().mockReturnValue(basePath), + }, + }, + ], }).compile(); service = module.get(NotificationsService); // this might need to be a module.resolve instead of get @@ -496,7 +491,15 @@ describe.concurrent('NotificationsService legacy script compatibility', () => { beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [NotificationsService], + providers: [ + NotificationsService, + { + provide: ConfigService, + useValue: { + get: vi.fn().mockReturnValue(basePath), + }, + }, + ], }).compile(); service = module.get(NotificationsService); diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts index 8daa13a98a..1a6fa5ffd7 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { readdir, readFile, rename, stat, unlink, writeFile } from 'fs/promises'; import { basename, join } from 'path'; @@ -55,11 +56,15 @@ export class NotificationsService { }, }; - constructor() { - this.path = getters.dynamix().notify!.path; + constructor(private readonly configService: ConfigService) { + this.path = this.getConfiguredPath(); void this.getNotificationsWatcher(this.path); } + private getConfiguredPath() { + return this.configService.get('store.dynamix.notify.path', '/tmp/notifications'); + } + /** * Returns the paths to the notification directories. * @@ -69,7 +74,7 @@ export class NotificationsService { * - path to the archived notifications */ public paths(): Record<'basePath' | NotificationType, string> { - const basePath = getters.dynamix().notify!.path; + const basePath = this.getConfiguredPath(); if (this.path !== basePath) { // Recreate the watcher with force = true diff --git a/api/src/unraid-api/plugin/plugin.service.ts b/api/src/unraid-api/plugin/plugin.service.ts index 8d2fbf0764..15ca041d28 100644 --- a/api/src/unraid-api/plugin/plugin.service.ts +++ b/api/src/unraid-api/plugin/plugin.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import type { ApiNestPluginDefinition } from '@app/unraid-api/plugin/plugin.interface.js'; import { pluginLogger } from '@app/core/log.js'; @@ -50,7 +51,7 @@ export class PluginService { }; } catch (error) { PluginService.logger.error(`Plugin from ${pkgName} is invalid: %o`, error as object); - const notificationService = new NotificationsService(); + const notificationService = new NotificationsService(new ConfigService()); const errorMessage = error?.toString?.() ?? (error as Error)?.message ?? ''; await notificationService.createNotification({ title: `Plugin from ${pkgName} is invalid`,