diff --git a/api/src/unraid-api/cli/__test__/pm2-commands.spec.ts b/api/src/unraid-api/cli/__test__/pm2-commands.spec.ts new file mode 100644 index 0000000000..0bac4ba273 --- /dev/null +++ b/api/src/unraid-api/cli/__test__/pm2-commands.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ECOSYSTEM_PATH } from '@app/environment.js'; +import { LogService } from '@app/unraid-api/cli/log.service.js'; +import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; +import { RestartCommand } from '@app/unraid-api/cli/restart.command.js'; +import { StartCommand } from '@app/unraid-api/cli/start.command.js'; +import { StatusCommand } from '@app/unraid-api/cli/status.command.js'; +import { StopCommand } from '@app/unraid-api/cli/stop.command.js'; + +const createLogger = (): LogService => + ({ + trace: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + log: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }) as unknown as LogService; + +const createPm2Service = () => + ({ + run: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + ensurePm2Dependencies: vi.fn().mockResolvedValue(undefined), + deleteDump: vi.fn().mockResolvedValue(undefined), + deletePm2Home: vi.fn().mockResolvedValue(undefined), + forceKillPm2Daemon: vi.fn().mockResolvedValue(undefined), + }) as unknown as PM2Service; + +describe('PM2-backed CLI commands', () => { + it('start clears PM2 state and starts without mini-list', async () => { + const logger = createLogger(); + const pm2 = createPm2Service(); + const command = new StartCommand(logger, pm2); + + await command.run([], { logLevel: 'info' }); + + expect(pm2.ensurePm2Dependencies).toHaveBeenCalledTimes(1); + expect(pm2.deleteDump).toHaveBeenCalledTimes(1); + expect(pm2.deletePm2Home).toHaveBeenCalledTimes(1); + expect(pm2.run).toHaveBeenNthCalledWith(1, { tag: 'PM2 Stop' }, 'stop', ECOSYSTEM_PATH); + expect(pm2.run).toHaveBeenNthCalledWith(2, { tag: 'PM2 Update' }, 'update'); + expect(pm2.run).toHaveBeenNthCalledWith(3, { tag: 'PM2 Delete' }, 'delete', ECOSYSTEM_PATH); + expect(pm2.run).toHaveBeenNthCalledWith(4, { tag: 'PM2 Kill' }, 'kill', '--no-autorestart'); + expect(pm2.run).toHaveBeenNthCalledWith( + 5, + { tag: 'PM2 Start', raw: true, extendEnv: true, env: { LOG_LEVEL: 'info' } }, + 'start', + ECOSYSTEM_PATH, + '--update-env' + ); + expect(vi.mocked(pm2.run).mock.calls.flat()).not.toContain('--mini-list'); + }); + + it('restart omits mini-list from the PM2 restart call', async () => { + const logger = createLogger(); + const pm2 = createPm2Service(); + const command = new RestartCommand(logger, pm2); + + await command.run([], { logLevel: 'info' }); + + expect(pm2.run).toHaveBeenCalledWith( + { tag: 'PM2 Restart', raw: true, extendEnv: true, env: { LOG_LEVEL: 'info' } }, + 'restart', + ECOSYSTEM_PATH, + '--update-env' + ); + expect(vi.mocked(pm2.run).mock.calls.flat()).not.toContain('--mini-list'); + }); + + it('status omits mini-list from the PM2 status call', async () => { + const pm2 = createPm2Service(); + const command = new StatusCommand(pm2); + + await command.run(); + + expect(pm2.run).toHaveBeenCalledWith( + { tag: 'PM2 Status', stdio: 'inherit', raw: true }, + 'status', + 'unraid-api' + ); + expect(vi.mocked(pm2.run).mock.calls.flat()).not.toContain('--mini-list'); + }); + + it('stop omits mini-list from the PM2 delete call', async () => { + const pm2 = createPm2Service(); + const command = new StopCommand(pm2); + + await command.run([], { delete: false }); + + expect(pm2.run).toHaveBeenCalledWith( + { tag: 'PM2 Delete', stdio: 'inherit' }, + 'delete', + ECOSYSTEM_PATH, + '--no-autorestart' + ); + expect(vi.mocked(pm2.run).mock.calls.flat()).not.toContain('--mini-list'); + }); +}); diff --git a/api/src/unraid-api/cli/restart.command.ts b/api/src/unraid-api/cli/restart.command.ts index 66d54a513e..da6005a68c 100644 --- a/api/src/unraid-api/cli/restart.command.ts +++ b/api/src/unraid-api/cli/restart.command.ts @@ -35,8 +35,7 @@ export class RestartCommand extends CommandRunner { { tag: 'PM2 Restart', raw: true, extendEnv: true, env }, 'restart', ECOSYSTEM_PATH, - '--update-env', - '--mini-list' + '--update-env' ); if (stderr) { diff --git a/api/src/unraid-api/cli/start.command.ts b/api/src/unraid-api/cli/start.command.ts index 64c7d890d0..2bcf6c6d2d 100644 --- a/api/src/unraid-api/cli/start.command.ts +++ b/api/src/unraid-api/cli/start.command.ts @@ -23,6 +23,8 @@ export class StartCommand extends CommandRunner { await this.pm2.run({ tag: 'PM2 Update' }, 'update'); await this.pm2.deleteDump(); await this.pm2.run({ tag: 'PM2 Delete' }, 'delete', ECOSYSTEM_PATH); + await this.pm2.run({ tag: 'PM2 Kill' }, 'kill', '--no-autorestart'); + await this.pm2.deletePm2Home(); } async run(_: string[], options: LogLevelOptions): Promise { @@ -33,8 +35,7 @@ export class StartCommand extends CommandRunner { { tag: 'PM2 Start', raw: true, extendEnv: true, env }, 'start', ECOSYSTEM_PATH, - '--update-env', - '--mini-list' + '--update-env' ); if (stdout) { this.logger.log(stdout.toString()); diff --git a/api/src/unraid-api/cli/status.command.ts b/api/src/unraid-api/cli/status.command.ts index 6e1b6b6e2e..4e78e938ab 100644 --- a/api/src/unraid-api/cli/status.command.ts +++ b/api/src/unraid-api/cli/status.command.ts @@ -8,11 +8,6 @@ export class StatusCommand extends CommandRunner { super(); } async run(): Promise { - await this.pm2.run( - { tag: 'PM2 Status', stdio: 'inherit', raw: true }, - 'status', - 'unraid-api', - '--mini-list' - ); + await this.pm2.run({ tag: 'PM2 Status', stdio: 'inherit', raw: true }, 'status', 'unraid-api'); } } diff --git a/api/src/unraid-api/cli/stop.command.ts b/api/src/unraid-api/cli/stop.command.ts index 995dd07437..f496263ec5 100644 --- a/api/src/unraid-api/cli/stop.command.ts +++ b/api/src/unraid-api/cli/stop.command.ts @@ -33,8 +33,7 @@ export class StopCommand extends CommandRunner { { tag: 'PM2 Delete', stdio: 'inherit' }, 'delete', ECOSYSTEM_PATH, - '--no-autorestart', - '--mini-list' + '--no-autorestart' ); } } diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts index 04945e7f25..1e736fd3b4 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts @@ -33,27 +33,30 @@ describe('DiskSensorsService', () => { }); describe('isAvailable', () => { - it('should return true when disks exist', async () => { + it('should return true without checking disks', async () => { vi.mocked(disksService.getDisks).mockResolvedValue([ { id: 'disk1', device: '/dev/sda', name: 'Test Disk' } as unknown as Disk, ]); const available = await service.isAvailable(); expect(available).toBe(true); + expect(disksService.getDisks).not.toHaveBeenCalled(); }); - it('should return false when no disks exist', async () => { + it('should return true when no disks exist', async () => { vi.mocked(disksService.getDisks).mockResolvedValue([]); const available = await service.isAvailable(); - expect(available).toBe(false); + expect(available).toBe(true); + expect(disksService.getDisks).not.toHaveBeenCalled(); }); - it('should return false when DisksService throws', async () => { + it('should return true when DisksService would throw', async () => { vi.mocked(disksService.getDisks).mockRejectedValue(new Error('Failed')); const available = await service.isAvailable(); - expect(available).toBe(false); + expect(available).toBe(true); + expect(disksService.getDisks).not.toHaveBeenCalled(); }); }); diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.ts index 1ab741a6ab..52b3216e28 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.ts @@ -18,13 +18,7 @@ export class DiskSensorsService implements TemperatureSensorProvider { constructor(private readonly disksService: DisksService) {} async isAvailable(): Promise { - // Disks are always "available" since DisksService exists - try { - const disks = await this.disksService.getDisks(); - return disks.length > 0; - } catch { - return false; - } + return true; } async read(): Promise { diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts index 6699066a1c..18ffe7d697 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -2,6 +2,7 @@ import { ConfigService } from '@nestjs/config'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AppReadyEvent } from '@app/unraid-api/app/app-lifecycle.events.js'; import { DiskSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.js'; import { IpmiSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.js'; import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.js'; @@ -80,7 +81,7 @@ describe('TemperatureService', () => { describe('initialization', () => { it('should initialize available providers', async () => { - await service.onModuleInit(); + await service.initializeProviders(); expect(lmSensors.isAvailable).toHaveBeenCalled(); expect(diskSensors.isAvailable).toHaveBeenCalled(); @@ -89,17 +90,43 @@ describe('TemperatureService', () => { it('should handle provider initialization errors gracefully', async () => { vi.mocked(lmSensors.isAvailable!).mockRejectedValue(new Error('Failed')); - await service.onModuleInit(); + await service.initializeProviders(); // Should not throw const metrics = await service.getMetrics(); expect(metrics).toBeDefined(); }); + + it('should initialize providers when the app ready event is emitted', async () => { + const event: AppReadyEvent = { + reason: 'nestjs-server-listening', + }; + + await service.handleAppReady(event); + + expect(lmSensors.isAvailable).toHaveBeenCalled(); + expect(diskSensors.isAvailable).toHaveBeenCalled(); + }); + + it('should warn when initialization fails after the app is ready', async () => { + const event: AppReadyEvent = { + reason: 'nestjs-server-listening', + }; + const warnSpy = vi.spyOn(service['logger'], 'warn'); + vi.spyOn(service, 'initializeProviders').mockRejectedValue(new Error('disk scan failed')); + + await service.handleAppReady(event); + + expect(warnSpy).toHaveBeenCalledWith( + 'Temperature provider initialization after startup failed', + expect.any(Error) + ); + }); }); describe('getMetrics', () => { beforeEach(async () => { - await service.onModuleInit(); + await service.initializeProviders(); }); it('should return temperature metrics', async () => { @@ -123,7 +150,7 @@ describe('TemperatureService', () => { history, temperatureConfigService ); - await emptyService.onModuleInit(); + await emptyService.initializeProviders(); const metrics = await emptyService.getMetrics(); expect(metrics).toBeNull(); @@ -155,7 +182,7 @@ describe('TemperatureService', () => { thresholds: { cpu_warning: 60, cpu_critical: 80 }, }); - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { @@ -182,7 +209,7 @@ describe('TemperatureService', () => { thresholds: {}, }); - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { @@ -210,7 +237,7 @@ describe('TemperatureService', () => { thresholds: {}, }); - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { @@ -239,7 +266,7 @@ describe('TemperatureService', () => { thresholds: {}, }); - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { @@ -269,7 +296,7 @@ describe('TemperatureService', () => { thresholds: { cpu_warning: 160 }, }); - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { @@ -294,7 +321,7 @@ describe('TemperatureService', () => { describe('buildSummary', () => { it('should calculate correct average', async () => { - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { id: 'sensor1', @@ -317,7 +344,7 @@ describe('TemperatureService', () => { }); it('should identify hottest and coolest sensors', async () => { - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { id: 's1', @@ -349,7 +376,7 @@ describe('TemperatureService', () => { }); describe('edge cases', () => { it('should handle provider read timeout gracefully', async () => { - await service.onModuleInit(); + await service.initializeProviders(); // Simulate a slow/hanging provider vi.mocked(lmSensors.read!).mockImplementation( @@ -362,7 +389,7 @@ describe('TemperatureService', () => { }, 10000); it('should deduplicate sensors with same ID from different providers', async () => { - await service.onModuleInit(); + await service.initializeProviders(); // Both providers return a sensor with the same ID vi.mocked(lmSensors.read!).mockResolvedValue([ @@ -391,7 +418,7 @@ describe('TemperatureService', () => { }); it('should handle empty sensor name', async () => { - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { @@ -409,7 +436,7 @@ describe('TemperatureService', () => { }); it('should handle negative temperature values', async () => { - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { @@ -428,7 +455,7 @@ describe('TemperatureService', () => { }); it('should handle extremely high temperature values', async () => { - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { @@ -447,7 +474,7 @@ describe('TemperatureService', () => { }); it('should handle NaN temperature values', async () => { - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { @@ -465,7 +492,7 @@ describe('TemperatureService', () => { }); it('should handle mix of valid and NaN temperature values', async () => { - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { @@ -493,7 +520,7 @@ describe('TemperatureService', () => { }); it('should handle all providers failing', async () => { - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockRejectedValue(new Error('lm-sensors failed')); vi.mocked(diskSensors.read!).mockRejectedValue(new Error('disk sensors failed')); @@ -504,7 +531,7 @@ describe('TemperatureService', () => { }); it('should handle partial provider failures', async () => { - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockRejectedValue(new Error('lm-sensors failed')); vi.mocked(diskSensors.read!).mockResolvedValue([ diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index d27340d601..4bb2d4698d 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -1,5 +1,8 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import type { AppReadyEvent } from '@app/unraid-api/app/app-lifecycle.events.js'; +import { APP_READY_EVENT } from '@app/unraid-api/app/app-lifecycle.events.js'; import { DiskSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.js'; import { IpmiSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.js'; import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.js'; @@ -21,9 +24,11 @@ import { // temperature.service.ts @Injectable() -export class TemperatureService implements OnModuleInit { +export class TemperatureService { private readonly logger = new Logger(TemperatureService.name); private availableProviders: TemperatureSensorProvider[] = []; + private initializationPromise: Promise | null = null; + private initialized = false; private cache: TemperatureMetrics | null = null; private cacheTimestamp = 0; @@ -40,17 +45,38 @@ export class TemperatureService implements OnModuleInit { private readonly configService: TemperatureConfigService ) {} - async onModuleInit() { - // Initialize all providers and check availability - await this.initializeProviders(); + async initializeProviders(): Promise { + if (this.initialized) { + return; + } + + if (this.initializationPromise) { + return this.initializationPromise; + } + + this.initializationPromise = this.loadAvailableProviders().finally(() => { + this.initializationPromise = null; + }); + + return this.initializationPromise; } - private async initializeProviders(): Promise { + @OnEvent(APP_READY_EVENT, { async: true }) + async handleAppReady(_event: AppReadyEvent): Promise { + try { + await this.initializeProviders(); + } catch (error: unknown) { + this.logger.warn('Temperature provider initialization after startup failed', error); + } + } + + private async loadAvailableProviders(): Promise { // 1. Get sensor specific configs const config = this.configService.getConfig(false); const lmSensorsConfig = config?.sensors?.lm_sensors; const smartctlConfig = config?.sensors?.smartctl; const ipmiConfig = config?.sensors?.ipmi; + const availableProviders: TemperatureSensorProvider[] = []; // 2. Define providers with their config checks // We default to TRUE if the config is missing @@ -79,7 +105,7 @@ export class TemperatureService implements OnModuleInit { try { if (await provider.service.isAvailable()) { - this.availableProviders.push(provider.service); + availableProviders.push(provider.service); this.logger.log(`Temperature provider available: ${provider.service.id}`); } else { this.logger.debug(`Temperature provider not available: ${provider.service.id}`); @@ -89,12 +115,17 @@ export class TemperatureService implements OnModuleInit { } } + this.availableProviders = availableProviders; + this.initialized = true; + if (this.availableProviders.length === 0) { this.logger.warn('No temperature providers available'); } } async getMetrics(): Promise { + await this.initializeProviders(); + // Check if we can use recent history instead of re-reading sensors const mostRecent = this.history.getMostRecentReading(); const canUseHistory = diff --git a/packages/unraid-api-plugin-connect/src/__test__/connect-startup-tasks.test.ts b/packages/unraid-api-plugin-connect/src/__test__/connect-startup-tasks.test.ts new file mode 100644 index 0000000000..cef9de1e8f --- /dev/null +++ b/packages/unraid-api-plugin-connect/src/__test__/connect-startup-tasks.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { MothershipController } from '../mothership-proxy/mothership.controller.js'; +import { DynamicRemoteAccessService } from '../remote-access/dynamic-remote-access.service.js'; +import { + ConnectStartupTasksListener, + runConnectStartupTasks, +} from '../startup/connect-startup-tasks.js'; + +describe('runConnectStartupTasks', () => { + it('runs connect startup work immediately', async () => { + const initRemoteAccess = vi.fn().mockResolvedValue(undefined); + const initOrRestart = vi.fn().mockResolvedValue(undefined); + const logger = { + info: vi.fn(), + warn: vi.fn(), + }; + + await runConnectStartupTasks( + { + dynamicRemoteAccessService: { initRemoteAccess }, + mothershipController: { initOrRestart }, + }, + logger + ); + + expect(initRemoteAccess).toHaveBeenCalledTimes(1); + expect(initOrRestart).toHaveBeenCalledTimes(1); + }); + + it('warns when a connect startup task rejects', async () => { + const backgroundError = new Error('network unavailable'); + const logger = { + info: vi.fn(), + warn: vi.fn(), + }; + + await runConnectStartupTasks( + { + dynamicRemoteAccessService: { + initRemoteAccess: vi.fn().mockRejectedValue(backgroundError), + }, + }, + logger + ); + + expect(logger.warn).toHaveBeenCalledWith( + 'Dynamic remote access startup failed', + backgroundError + ); + }); + + it('still runs mothership startup when remote access startup rejects', async () => { + const backgroundError = new Error('network unavailable'); + const initOrRestart = vi.fn().mockResolvedValue(undefined); + const logger = { + info: vi.fn(), + warn: vi.fn(), + }; + + await runConnectStartupTasks( + { + dynamicRemoteAccessService: { + initRemoteAccess: vi.fn().mockRejectedValue(backgroundError), + }, + mothershipController: { initOrRestart }, + }, + logger + ); + + expect(initOrRestart).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + 'Dynamic remote access startup failed', + backgroundError + ); + }); + + it('does nothing when connect providers are unavailable', async () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + }; + + await expect(runConnectStartupTasks({}, logger)).resolves.toBeUndefined(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); + +describe('ConnectStartupTasksListener', () => { + it('runs connect startup work when the app ready event is emitted', async () => { + const initRemoteAccess = vi.fn().mockResolvedValue(undefined); + const initOrRestart = vi.fn().mockResolvedValue(undefined); + const listener = new ConnectStartupTasksListener({ initRemoteAccess }, { initOrRestart }); + const event = { + reason: 'nestjs-server-listening', + } as const; + + await listener.handleAppReady(event); + + expect(initRemoteAccess).toHaveBeenCalledTimes(1); + expect(initOrRestart).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.controller.ts b/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.controller.ts index 237479aa3f..63b7871a55 100644 --- a/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.controller.ts +++ b/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.controller.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common'; +import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; import { TimeoutCheckerJob } from '../connection-status/timeout-checker.job.js'; import { MothershipConnectionService } from './connection.service.js'; @@ -13,7 +13,7 @@ import { MothershipSubscriptionHandler } from './mothership-subscription.handler * - Connection service (controller for connection state & metadata) */ @Injectable() -export class MothershipController implements OnModuleDestroy, OnApplicationBootstrap { +export class MothershipController implements OnModuleDestroy { private readonly logger = new Logger(MothershipController.name); constructor( private readonly clientService: MothershipGraphqlClientService, @@ -26,10 +26,6 @@ export class MothershipController implements OnModuleDestroy, OnApplicationBoots await this.stop(); } - async onApplicationBootstrap() { - await this.initOrRestart(); - } - /** * Stops the mothership stack. Throws on first error. */ diff --git a/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.module.ts b/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.module.ts index 267b438262..b3170929f4 100644 --- a/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.module.ts +++ b/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.module.ts @@ -6,6 +6,7 @@ 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 { RemoteAccessModule } from '../remote-access/remote-access.module.js'; +import { ConnectStartupTasksListener } from '../startup/connect-startup-tasks.js'; import { MothershipConnectionService } from './connection.service.js'; import { MothershipGraphqlClientService } from './graphql.client.js'; import { MothershipSubscriptionHandler } from './mothership-subscription.handler.js'; @@ -16,6 +17,7 @@ import { MothershipHandler } from './mothership.events.js'; imports: [RemoteAccessModule], providers: [ ConnectStatusWriterService, + ConnectStartupTasksListener, MothershipConnectionService, MothershipGraphqlClientService, MothershipHandler, diff --git a/packages/unraid-api-plugin-connect/src/remote-access/dynamic-remote-access.service.ts b/packages/unraid-api-plugin-connect/src/remote-access/dynamic-remote-access.service.ts index 3e9deae89f..5c2d48ff0e 100644 --- a/packages/unraid-api-plugin-connect/src/remote-access/dynamic-remote-access.service.ts +++ b/packages/unraid-api-plugin-connect/src/remote-access/dynamic-remote-access.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { URL_TYPE } from '@unraid/shared/network.model.js'; @@ -15,7 +15,7 @@ import { StaticRemoteAccessService } from './static-remote-access.service.js'; import { UpnpRemoteAccessService } from './upnp-remote-access.service.js'; @Injectable() -export class DynamicRemoteAccessService implements OnApplicationBootstrap { +export class DynamicRemoteAccessService { private readonly logger = new Logger(DynamicRemoteAccessService.name); constructor( @@ -24,10 +24,6 @@ export class DynamicRemoteAccessService implements OnApplicationBootstrap { private readonly upnpRemoteAccessService: UpnpRemoteAccessService ) {} - async onApplicationBootstrap() { - await this.initRemoteAccess(); - } - /** * Get the current state of dynamic remote access */ @@ -146,7 +142,7 @@ export class DynamicRemoteAccessService implements OnApplicationBootstrap { this.clearError(); } - private async initRemoteAccess() { + async initRemoteAccess() { this.logger.verbose('Initializing Remote Access'); const { wanaccess, upnpEnabled } = this.configService.get('connect.config', { infer: true }); if (wanaccess && upnpEnabled) { diff --git a/packages/unraid-api-plugin-connect/src/startup/connect-startup-tasks.ts b/packages/unraid-api-plugin-connect/src/startup/connect-startup-tasks.ts new file mode 100644 index 0000000000..37451ae010 --- /dev/null +++ b/packages/unraid-api-plugin-connect/src/startup/connect-startup-tasks.ts @@ -0,0 +1,75 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Inject } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; + +import { MothershipController } from '../mothership-proxy/mothership.controller.js'; +import { DynamicRemoteAccessService } from '../remote-access/dynamic-remote-access.service.js'; +const APP_READY_EVENT = 'app.ready'; + +interface AppReadyEvent { + reason: 'nestjs-server-listening'; +} + +interface ConnectStartupRemoteAccess { + initRemoteAccess: () => Promise; +} + +interface ConnectStartupMothership { + initOrRestart: () => Promise; +} + +interface ConnectStartupTasksDependencies { + dynamicRemoteAccessService?: ConnectStartupRemoteAccess | null; + mothershipController?: ConnectStartupMothership | null; +} + +interface ConnectStartupLogger { + info: (message: string) => void; + warn: (message: string, error: unknown) => void; +} + +export const runConnectStartupTasks = async ( + { dynamicRemoteAccessService, mothershipController }: ConnectStartupTasksDependencies, + logger: ConnectStartupLogger +): Promise => { + if (!dynamicRemoteAccessService && !mothershipController) { + return; + } + + logger.info('Running Connect startup tasks after app.ready'); + + await Promise.allSettled([ + dynamicRemoteAccessService?.initRemoteAccess().catch((error: unknown) => { + logger.warn('Dynamic remote access startup failed', error); + }), + mothershipController?.initOrRestart().catch((error: unknown) => { + logger.warn('Mothership startup failed', error); + }), + ]); +}; + +@Injectable() +export class ConnectStartupTasksListener { + private readonly logger = new Logger(ConnectStartupTasksListener.name); + + constructor( + @Inject(DynamicRemoteAccessService) + private readonly dynamicRemoteAccessService: ConnectStartupRemoteAccess, + @Inject(MothershipController) + private readonly mothershipController: ConnectStartupMothership + ) {} + + @OnEvent(APP_READY_EVENT, { async: true }) + async handleAppReady(_event: AppReadyEvent): Promise { + await runConnectStartupTasks( + { + dynamicRemoteAccessService: this.dynamicRemoteAccessService, + mothershipController: this.mothershipController, + }, + { + info: (message: string) => this.logger.log(message), + warn: (message: string, error: unknown) => this.logger.warn(`${message}: ${String(error)}`), + } + ); + } +} diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index 6e8a60e695..7c307a782f 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -596,6 +596,14 @@ echo "DEBUG: Attempting to run unraid-api directly:" echo "If no additional messages appear within 30 seconds, it is safe to refresh the page." /etc/rc.d/rc.unraid-api plugins add unraid-api-plugin-connect -b --no-restart + +echo "Creating dependency recovery archive..." +if /etc/rc.d/rc.unraid-api archive-dependencies; then + echo "Dependency recovery archive created" +else + echo "⚠️ Warning: Failed to create dependency recovery archive" +fi + /etc/rc.d/rc.unraid-api start echo "Unraid API service started" diff --git a/plugin/source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh b/plugin/source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh index 0731bd976e..10670e6da0 100755 --- a/plugin/source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh +++ b/plugin/source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh @@ -96,6 +96,19 @@ check_symlink() { fi } +check_populated_dir() { + if [ -d "$1" ] && [ "$(ls -A "$1" 2>/dev/null)" ]; then + printf '✓ Directory %s exists and is populated\n' "$1" + return 0 + elif [ -d "$1" ]; then + printf '✗ Directory %s exists but is empty\n' "$1" + return 1 + else + printf '✗ Directory %s is missing\n' "$1" + return 1 + fi +} + # Check executable files echo "Checking executable files..." EXEC_ERRORS=0 @@ -138,6 +151,38 @@ else fi TOTAL_ERRORS=$((TOTAL_ERRORS + CONFIG_ERRORS)) +echo "Checking dependency installation..." +DEPENDENCY_ERRORS=0 +if ! check_populated_dir "/usr/local/unraid-api/node_modules"; then + DEPENDENCY_ERRORS=$((DEPENDENCY_ERRORS + 1)) +fi + +VENDOR_ARCHIVE_CONFIG="/usr/local/share/dynamix.unraid.net/config/vendor_archive.json" +if [ -f "$VENDOR_ARCHIVE_CONFIG" ]; then + printf '✓ Vendor archive config %s exists\n' "$VENDOR_ARCHIVE_CONFIG" + if command -v jq >/dev/null 2>&1; then + VENDOR_ARCHIVE_PATH=$(jq -r '.vendor_store_path' "$VENDOR_ARCHIVE_CONFIG" 2>/dev/null) + if [ -n "$VENDOR_ARCHIVE_PATH" ] && [ "$VENDOR_ARCHIVE_PATH" != "null" ]; then + if [ -f "$VENDOR_ARCHIVE_PATH" ]; then + printf '✓ Vendor archive %s exists\n' "$VENDOR_ARCHIVE_PATH" + else + printf '✗ Vendor archive %s is missing\n' "$VENDOR_ARCHIVE_PATH" + DEPENDENCY_ERRORS=$((DEPENDENCY_ERRORS + 1)) + fi + else + printf '✗ Vendor archive config %s is missing vendor_store_path\n' "$VENDOR_ARCHIVE_CONFIG" + DEPENDENCY_ERRORS=$((DEPENDENCY_ERRORS + 1)) + fi + else + printf '✗ jq is required to validate vendor archive config\n' + DEPENDENCY_ERRORS=$((DEPENDENCY_ERRORS + 1)) + fi +else + printf '✗ Vendor archive config %s is missing\n' "$VENDOR_ARCHIVE_CONFIG" + DEPENDENCY_ERRORS=$((DEPENDENCY_ERRORS + 1)) +fi +TOTAL_ERRORS=$((TOTAL_ERRORS + DEPENDENCY_ERRORS)) + # Check for proper Slackware-style shutdown configuration echo "Checking shutdown configuration..." SHUTDOWN_ERRORS=0 @@ -193,6 +238,7 @@ echo "- Executable files errors: $EXEC_ERRORS" echo "- Directory errors: $DIR_ERRORS" echo "- Symlink errors: $SYMLINK_ERRORS" echo "- Configuration errors: $CONFIG_ERRORS" +echo "- Dependency errors: $DEPENDENCY_ERRORS" echo "- Shutdown configuration errors: $SHUTDOWN_ERRORS" echo "- Total errors: $TOTAL_ERRORS" @@ -206,4 +252,4 @@ else echo "Please review the errors above and contact support if needed." # We don't exit with error as this is just a verification script exit 0 -fi \ No newline at end of file +fi