From e5e77321da5a67e582ecae6e18ef14bd69cf7566 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 19 Nov 2025 20:43:37 -0500 Subject: [PATCH 01/16] Include nodemon config in build artifact --- api/ecosystem.config.json | 22 - api/generated-schema.graphql | 4 +- api/legacy/generated-schema-legacy.graphql | 2 +- api/nodemon.json | 18 + api/package.json | 3 +- api/scripts/build.ts | 4 +- .../__test__/core/utils/pm2/dummy-process.js | 5 - .../unraid-api-running.integration.test.ts | 222 ------- .../unraid-api-running.integration.test.ts | 54 ++ api/src/core/log.ts | 4 +- api/src/core/utils/pm2/unraid-api-running.ts | 40 -- .../core/utils/process/unraid-api-running.ts | 23 + api/src/environment.ts | 15 +- .../cli/__test__/report.command.test.ts | 8 +- api/src/unraid-api/cli/cli-services.module.ts | 4 +- api/src/unraid-api/cli/cli.module.ts | 4 +- api/src/unraid-api/cli/generated/graphql.ts | 19 +- api/src/unraid-api/cli/logs.command.ts | 13 +- .../unraid-api/cli/nodemon.service.spec.ts | 99 +++ api/src/unraid-api/cli/nodemon.service.ts | 133 ++++ api/src/unraid-api/cli/pm2.service.spec.ts | 76 --- api/src/unraid-api/cli/pm2.service.ts | 134 ---- api/src/unraid-api/cli/report.command.ts | 4 +- api/src/unraid-api/cli/restart.command.ts | 26 +- api/src/unraid-api/cli/start.command.ts | 31 +- api/src/unraid-api/cli/status.command.ts | 11 +- api/src/unraid-api/cli/stop.command.ts | 29 +- .../resolvers/info/versions/versions.model.ts | 4 +- .../info/versions/versions.resolver.ts | 3 +- api/src/unraid-api/main.ts | 10 - pnpm-lock.yaml | 607 +----------------- web/composables/gql/graphql.ts | 4 +- web/src/composables/gql/graphql.ts | 19 +- web/src/composables/gql/index.ts | 4 +- 34 files changed, 433 insertions(+), 1225 deletions(-) delete mode 100644 api/ecosystem.config.json create mode 100644 api/nodemon.json delete mode 100644 api/src/__test__/core/utils/pm2/dummy-process.js delete mode 100644 api/src/__test__/core/utils/pm2/unraid-api-running.integration.test.ts create mode 100644 api/src/__test__/core/utils/process/unraid-api-running.integration.test.ts delete mode 100644 api/src/core/utils/pm2/unraid-api-running.ts create mode 100644 api/src/core/utils/process/unraid-api-running.ts create mode 100644 api/src/unraid-api/cli/nodemon.service.spec.ts create mode 100644 api/src/unraid-api/cli/nodemon.service.ts delete mode 100644 api/src/unraid-api/cli/pm2.service.spec.ts delete mode 100644 api/src/unraid-api/cli/pm2.service.ts diff --git a/api/ecosystem.config.json b/api/ecosystem.config.json deleted file mode 100644 index 4fea24e6ef..0000000000 --- a/api/ecosystem.config.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/pm2-ecosystem", - "apps": [ - { - "name": "unraid-api", - "script": "./dist/main.js", - "cwd": "/usr/local/unraid-api", - "exec_mode": "fork", - "wait_ready": true, - "listen_timeout": 15000, - "max_restarts": 10, - "min_uptime": 10000, - "watch": false, - "interpreter": "/usr/local/bin/node", - "ignore_watch": ["node_modules", "src", ".env.*", "myservers.cfg"], - "out_file": "/var/log/graphql-api.log", - "error_file": "/var/log/graphql-api.log", - "merge_logs": true, - "kill_timeout": 10000 - } - ] -} diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 0dfe521f9e..f0fbb669df 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1673,8 +1673,8 @@ type PackageVersions { """npm version""" npm: String - """pm2 version""" - pm2: String + """nodemon version""" + nodemon: String """Git version""" git: String diff --git a/api/legacy/generated-schema-legacy.graphql b/api/legacy/generated-schema-legacy.graphql index 0928c60b90..b13c1ef314 100644 --- a/api/legacy/generated-schema-legacy.graphql +++ b/api/legacy/generated-schema-legacy.graphql @@ -1257,7 +1257,7 @@ type Versions { openssl: String perl: String php: String - pm2: String + nodemon: String postfix: String postgresql: String python: String diff --git a/api/nodemon.json b/api/nodemon.json new file mode 100644 index 0000000000..91e2dfae2a --- /dev/null +++ b/api/nodemon.json @@ -0,0 +1,18 @@ +{ + "watch": [ + "dist/main.js", + "myservers.cfg" + ], + "ignore": [ + "node_modules", + "src", + ".env.*" + ], + "exec": "node ./dist/main.js", + "signal": "SIGTERM", + "ext": "js,json", + "restartable": "rs", + "env": { + "NODE_ENV": "production" + } +} diff --git a/api/package.json b/api/package.json index 26e51095bf..a09f9500a4 100644 --- a/api/package.json +++ b/api/package.json @@ -137,7 +137,7 @@ "pino": "9.9.0", "pino-http": "10.5.0", "pino-pretty": "13.1.1", - "pm2": "6.0.8", + "nodemon": "3.1.10", "reflect-metadata": "^0.1.14", "rxjs": "7.8.2", "semver": "7.7.2", @@ -203,7 +203,6 @@ "eslint-plugin-no-relative-import-paths": "1.6.1", "eslint-plugin-prettier": "5.5.4", "jiti": "2.5.1", - "nodemon": "3.1.10", "prettier": "3.6.2", "rollup-plugin-node-externals": "8.1.0", "supertest": "7.1.4", diff --git a/api/scripts/build.ts b/api/scripts/build.ts index 924b3f4ca3..6c6bb98ac5 100755 --- a/api/scripts/build.ts +++ b/api/scripts/build.ts @@ -7,7 +7,7 @@ import { exit } from 'process'; import type { PackageJson } from 'type-fest'; import { $, cd } from 'zx'; -import { getDeploymentVersion } from './get-deployment-version.js'; +import { getDeploymentVersion } from '@app/../scripts/get-deployment-version.js'; type ApiPackageJson = PackageJson & { version: string; @@ -94,7 +94,7 @@ try { await writeFile('./deploy/pack/package.json', JSON.stringify(parsedPackageJson, null, 4)); // Copy necessary files to the pack directory - await $`cp -r dist README.md .env.* ecosystem.config.json ./deploy/pack/`; + await $`cp -r dist README.md .env.* nodemon.json ./deploy/pack/`; // Change to the pack directory and install dependencies cd('./deploy/pack'); diff --git a/api/src/__test__/core/utils/pm2/dummy-process.js b/api/src/__test__/core/utils/pm2/dummy-process.js deleted file mode 100644 index 85ace81c08..0000000000 --- a/api/src/__test__/core/utils/pm2/dummy-process.js +++ /dev/null @@ -1,5 +0,0 @@ -/* eslint-disable no-undef */ -// Dummy process for PM2 testing -setInterval(() => { - // Keep process alive -}, 1000); \ No newline at end of file diff --git a/api/src/__test__/core/utils/pm2/unraid-api-running.integration.test.ts b/api/src/__test__/core/utils/pm2/unraid-api-running.integration.test.ts deleted file mode 100644 index 6c05e817c6..0000000000 --- a/api/src/__test__/core/utils/pm2/unraid-api-running.integration.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { existsSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { execa } from 'execa'; -import pm2 from 'pm2'; -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; - -import { isUnraidApiRunning } from '@app/core/utils/pm2/unraid-api-running.js'; - -const __dirname = fileURLToPath(new URL('.', import.meta.url)); -const PROJECT_ROOT = join(__dirname, '../../../../..'); -const DUMMY_PROCESS_PATH = join(__dirname, 'dummy-process.js'); -const CLI_PATH = join(PROJECT_ROOT, 'dist/cli.js'); -const TEST_PROCESS_NAME = 'test-unraid-api'; - -// Shared PM2 connection state -let pm2Connected = false; - -// Helper to ensure PM2 connection is established -async function ensurePM2Connection() { - if (pm2Connected) return; - - return new Promise((resolve, reject) => { - pm2.connect((err) => { - if (err) { - reject(err); - return; - } - pm2Connected = true; - resolve(); - }); - }); -} - -// Helper to delete specific test processes (lightweight, reuses connection) -async function deleteTestProcesses() { - if (!pm2Connected) { - // No connection, nothing to clean up - return; - } - - const deletePromise = new Promise((resolve) => { - // Delete specific processes we might have created - const processNames = ['unraid-api', TEST_PROCESS_NAME]; - let deletedCount = 0; - - const deleteNext = () => { - if (deletedCount >= processNames.length) { - resolve(); - return; - } - - const processName = processNames[deletedCount]; - pm2.delete(processName, () => { - // Ignore errors, process might not exist - deletedCount++; - deleteNext(); - }); - }; - - deleteNext(); - }); - - const timeoutPromise = new Promise((resolve) => { - setTimeout(() => resolve(), 3000); // 3 second timeout - }); - - return Promise.race([deletePromise, timeoutPromise]); -} - -// Helper to ensure PM2 is completely clean (heavy cleanup with daemon kill) -async function cleanupAllPM2Processes() { - // First delete test processes if we have a connection - if (pm2Connected) { - await deleteTestProcesses(); - } - - return new Promise((resolve) => { - // Always connect fresh for daemon kill (in case we weren't connected) - pm2.connect((err) => { - if (err) { - // If we can't connect, assume PM2 is not running - pm2Connected = false; - resolve(); - return; - } - - // Kill the daemon to ensure fresh state - pm2.killDaemon(() => { - pm2.disconnect(); - pm2Connected = false; - // Small delay to let PM2 fully shutdown - setTimeout(resolve, 500); - }); - }); - }); -} - -describe.skipIf(!!process.env.CI)('PM2 integration tests', () => { - beforeAll(async () => { - // Set PM2_HOME to use home directory for testing (not /var/log) - process.env.PM2_HOME = join(homedir(), '.pm2'); - - // Build the CLI if it doesn't exist (only for CLI tests) - if (!existsSync(CLI_PATH)) { - console.log('Building CLI for integration tests...'); - try { - await execa('pnpm', ['build'], { - cwd: PROJECT_ROOT, - stdio: 'inherit', - timeout: 120000, // 2 minute timeout for build - }); - } catch (error) { - console.error('Failed to build CLI:', error); - throw new Error( - 'Cannot run CLI integration tests without built CLI. Run `pnpm build` first.' - ); - } - } - - // Only do a full cleanup once at the beginning - await cleanupAllPM2Processes(); - }, 150000); // 2.5 minute timeout for setup - - afterAll(async () => { - // Only do a full cleanup once at the end - await cleanupAllPM2Processes(); - }); - - afterEach(async () => { - // Lightweight cleanup after each test - just delete our test processes - await deleteTestProcesses(); - }, 5000); // 5 second timeout for cleanup - - describe('isUnraidApiRunning function', () => { - it('should return false when PM2 is not running the unraid-api process', async () => { - const result = await isUnraidApiRunning(); - expect(result).toBe(false); - }); - - it('should return true when PM2 has unraid-api process running', async () => { - // Ensure PM2 connection - await ensurePM2Connection(); - - // Start a dummy process with the name 'unraid-api' - await new Promise((resolve, reject) => { - pm2.start( - { - script: DUMMY_PROCESS_PATH, - name: 'unraid-api', - }, - (startErr) => { - if (startErr) return reject(startErr); - resolve(); - } - ); - }); - - // Give PM2 time to start the process - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const result = await isUnraidApiRunning(); - expect(result).toBe(true); - }, 30000); - - it('should return false when unraid-api process is stopped', async () => { - // Ensure PM2 connection - await ensurePM2Connection(); - - // Start and then stop the process - await new Promise((resolve, reject) => { - pm2.start( - { - script: DUMMY_PROCESS_PATH, - name: 'unraid-api', - }, - (startErr) => { - if (startErr) return reject(startErr); - - // Stop the process after starting - setTimeout(() => { - pm2.stop('unraid-api', (stopErr) => { - if (stopErr) return reject(stopErr); - resolve(); - }); - }, 1000); - } - ); - }); - - await new Promise((resolve) => setTimeout(resolve, 1000)); - - const result = await isUnraidApiRunning(); - expect(result).toBe(false); - }, 30000); - - it('should handle PM2 connection errors gracefully', async () => { - // Disconnect PM2 first to ensure we're testing fresh connection - await new Promise((resolve) => { - pm2.disconnect(); - pm2Connected = false; - setTimeout(resolve, 100); - }); - - // Set an invalid PM2_HOME to force connection failure - const originalPM2Home = process.env.PM2_HOME; - process.env.PM2_HOME = '/invalid/path/that/does/not/exist'; - - const result = await isUnraidApiRunning(); - expect(result).toBe(false); - - // Restore original PM2_HOME - if (originalPM2Home) { - process.env.PM2_HOME = originalPM2Home; - } else { - delete process.env.PM2_HOME; - } - }, 15000); // 15 second timeout to allow for the Promise.race timeout - }); -}); diff --git a/api/src/__test__/core/utils/process/unraid-api-running.integration.test.ts b/api/src/__test__/core/utils/process/unraid-api-running.integration.test.ts new file mode 100644 index 0000000000..124641e68d --- /dev/null +++ b/api/src/__test__/core/utils/process/unraid-api-running.integration.test.ts @@ -0,0 +1,54 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +describe('isUnraidApiRunning (nodemon pid detection)', () => { + let tempDir: string; + let pidPath: string; + + beforeAll(() => { + tempDir = mkdtempSync(join(tmpdir(), 'unraid-api-')); + pidPath = join(tempDir, 'nodemon.pid'); + }); + + afterAll(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + afterEach(() => { + vi.resetModules(); + }); + + async function loadIsRunning() { + vi.doMock('@app/environment.js', async () => { + const actual = + await vi.importActual('@app/environment.js'); + return { ...actual, NODEMON_PID_PATH: pidPath }; + }); + + const module = await import('@app/core/utils/process/unraid-api-running.js'); + return module.isUnraidApiRunning; + } + + it('returns false when pid file is missing', async () => { + const isUnraidApiRunning = await loadIsRunning(); + + expect(await isUnraidApiRunning()).toBe(false); + }); + + it('returns true when a live pid is recorded', async () => { + writeFileSync(pidPath, `${process.pid}`); + const isUnraidApiRunning = await loadIsRunning(); + + expect(await isUnraidApiRunning()).toBe(true); + }); + + it('returns false when pid file is invalid', async () => { + writeFileSync(pidPath, 'not-a-number'); + const isUnraidApiRunning = await loadIsRunning(); + + expect(await isUnraidApiRunning()).toBe(false); + }); +}); diff --git a/api/src/core/log.ts b/api/src/core/log.ts index 84f66601fa..4d0311b35a 100644 --- a/api/src/core/log.ts +++ b/api/src/core/log.ts @@ -17,7 +17,7 @@ const nullDestination = pino.destination({ export const logDestination = process.env.SUPPRESS_LOGS === 'true' ? nullDestination : pino.destination(); -// Since PM2 captures stdout and writes to the log file, we should not colorize stdout +// Since process output is piped directly to the log file, we should not colorize stdout // to avoid ANSI escape codes in the log file const stream = SUPPRESS_LOGS ? nullDestination @@ -25,7 +25,7 @@ const stream = SUPPRESS_LOGS ? pretty({ singleLine: true, hideObject: false, - colorize: false, // No colors since PM2 writes stdout to file + colorize: false, // No colors since logs are written directly to file colorizeObjects: false, levelFirst: false, ignore: 'hostname,pid', diff --git a/api/src/core/utils/pm2/unraid-api-running.ts b/api/src/core/utils/pm2/unraid-api-running.ts deleted file mode 100644 index 4e65aa3ac9..0000000000 --- a/api/src/core/utils/pm2/unraid-api-running.ts +++ /dev/null @@ -1,40 +0,0 @@ -export const isUnraidApiRunning = async (): Promise => { - const { PM2_HOME } = await import('@app/environment.js'); - - // Set PM2_HOME if not already set - if (!process.env.PM2_HOME) { - process.env.PM2_HOME = PM2_HOME; - } - - const pm2Module = await import('pm2'); - const pm2 = pm2Module.default || pm2Module; - - const pm2Promise = new Promise((resolve) => { - pm2.connect(function (err) { - if (err) { - // Don't reject here, resolve with false since we can't connect to PM2 - resolve(false); - return; - } - - // Now try to describe unraid-api specifically - pm2.describe('unraid-api', function (err, processDescription) { - if (err || processDescription.length === 0) { - // Service not found or error occurred - resolve(false); - } else { - const isOnline = processDescription?.[0]?.pm2_env?.status === 'online'; - resolve(isOnline); - } - - pm2.disconnect(); - }); - }); - }); - - const timeoutPromise = new Promise((resolve) => { - setTimeout(() => resolve(false), 10000); // 10 second timeout - }); - - return Promise.race([pm2Promise, timeoutPromise]); -}; diff --git a/api/src/core/utils/process/unraid-api-running.ts b/api/src/core/utils/process/unraid-api-running.ts new file mode 100644 index 0000000000..d1361b21fa --- /dev/null +++ b/api/src/core/utils/process/unraid-api-running.ts @@ -0,0 +1,23 @@ +import { readFile } from 'node:fs/promises'; + +import { fileExists } from '@app/core/utils/files/file-exists.js'; +import { NODEMON_PID_PATH } from '@app/environment.js'; + +export const isUnraidApiRunning = async (): Promise => { + if (!(await fileExists(NODEMON_PID_PATH))) { + return false; + } + + const pidText = (await readFile(NODEMON_PID_PATH, 'utf-8')).trim(); + const pid = Number.parseInt(pidText, 10); + if (Number.isNaN(pid)) { + return false; + } + + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; diff --git a/api/src/environment.ts b/api/src/environment.ts index b1d3c2bad3..3ab43336bf 100644 --- a/api/src/environment.ts +++ b/api/src/environment.ts @@ -98,13 +98,22 @@ export const MOTHERSHIP_GRAPHQL_LINK = process.env.MOTHERSHIP_GRAPHQL_LINK ? 'https://staging.mothership.unraid.net/ws' : 'https://mothership.unraid.net/ws'; -export const PM2_HOME = process.env.PM2_HOME ?? '/var/log/.pm2'; -export const PM2_PATH = join(import.meta.dirname, '../../', 'node_modules', 'pm2', 'bin', 'pm2'); -export const ECOSYSTEM_PATH = join(import.meta.dirname, '../../', 'ecosystem.config.json'); export const PATHS_LOGS_DIR = process.env.PATHS_LOGS_DIR ?? process.env.LOGS_DIR ?? '/var/log/unraid-api'; export const PATHS_LOGS_FILE = process.env.PATHS_LOGS_FILE ?? '/var/log/graphql-api.log'; +export const NODEMON_PATH = join( + import.meta.dirname, + '../../', + 'node_modules', + 'nodemon', + 'bin', + 'nodemon.js' +); +export const NODEMON_CONFIG_PATH = join(import.meta.dirname, '../../', 'nodemon.json'); +export const NODEMON_PID_PATH = process.env.NODEMON_PID_PATH ?? '/var/run/unraid-api/nodemon.pid'; +export const UNRAID_API_CWD = process.env.UNRAID_API_CWD ?? join(import.meta.dirname, '../../'); + export const PATHS_CONFIG_MODULES = process.env.PATHS_CONFIG_MODULES ?? '/boot/config/plugins/dynamix.my.servers/configs'; diff --git a/api/src/unraid-api/cli/__test__/report.command.test.ts b/api/src/unraid-api/cli/__test__/report.command.test.ts index bbedcfbaf2..ffdbd4d77a 100644 --- a/api/src/unraid-api/cli/__test__/report.command.test.ts +++ b/api/src/unraid-api/cli/__test__/report.command.test.ts @@ -26,10 +26,10 @@ const mockApiReportService = { generateReport: vi.fn(), }; -// Mock PM2 check +// Mock process manager check const mockIsUnraidApiRunning = vi.fn().mockResolvedValue(true); -vi.mock('@app/core/utils/pm2/unraid-api-running.js', () => ({ +vi.mock('@app/core/utils/process/unraid-api-running.js', () => ({ isUnraidApiRunning: () => mockIsUnraidApiRunning(), })); @@ -50,7 +50,7 @@ describe('ReportCommand', () => { // Clear mocks vi.clearAllMocks(); - // Reset PM2 mock to default + // Reset nodemon mock to default mockIsUnraidApiRunning.mockResolvedValue(true); }); @@ -150,7 +150,7 @@ describe('ReportCommand', () => { // Reset mocks vi.clearAllMocks(); - // Test with API running but PM2 check returns true + // Test with API running but status check returns true mockIsUnraidApiRunning.mockResolvedValue(true); await reportCommand.report(); expect(mockApiReportService.generateReport).toHaveBeenCalledWith(true); diff --git a/api/src/unraid-api/cli/cli-services.module.ts b/api/src/unraid-api/cli/cli-services.module.ts index 7f248390d0..a92c126944 100644 --- a/api/src/unraid-api/cli/cli-services.module.ts +++ b/api/src/unraid-api/cli/cli-services.module.ts @@ -4,7 +4,7 @@ import { DependencyService } from '@app/unraid-api/app/dependency.service.js'; import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js'; import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js'; import { LogService } from '@app/unraid-api/cli/log.service.js'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; +import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js'; import { LegacyConfigModule } from '@app/unraid-api/config/legacy-config.module.js'; import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js'; @@ -21,7 +21,7 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u PluginCliModule.register(), UnraidFileModifierModule, ], - providers: [LogService, PM2Service, ApiKeyService, DependencyService, ApiReportService], + providers: [LogService, NodemonService, ApiKeyService, DependencyService, ApiReportService], exports: [ApiReportService, LogService, ApiKeyService], }) export class CliServicesModule {} diff --git a/api/src/unraid-api/cli/cli.module.ts b/api/src/unraid-api/cli/cli.module.ts index 7befdcb0e4..9569475cb2 100644 --- a/api/src/unraid-api/cli/cli.module.ts +++ b/api/src/unraid-api/cli/cli.module.ts @@ -13,6 +13,7 @@ import { DeveloperCommand } from '@app/unraid-api/cli/developer/developer.comman import { DeveloperQuestions } from '@app/unraid-api/cli/developer/developer.questions.js'; import { LogService } from '@app/unraid-api/cli/log.service.js'; import { LogsCommand } from '@app/unraid-api/cli/logs.command.js'; +import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; import { InstallPluginCommand, ListPluginCommand, @@ -20,7 +21,6 @@ import { RemovePluginCommand, } from '@app/unraid-api/cli/plugins/plugin.command.js'; import { RemovePluginQuestionSet } from '@app/unraid-api/cli/plugins/remove-plugin.questions.js'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; import { ReportCommand } from '@app/unraid-api/cli/report.command.js'; import { RestartCommand } from '@app/unraid-api/cli/restart.command.js'; import { SSOCommand } from '@app/unraid-api/cli/sso/sso.command.js'; @@ -64,7 +64,7 @@ const DEFAULT_PROVIDERS = [ DeveloperQuestions, DeveloperToolsService, LogService, - PM2Service, + NodemonService, ApiKeyService, DependencyService, ApiReportService, diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index 97e116fcbb..2c991a9431 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -559,6 +559,17 @@ export type CpuLoad = { percentUser: Scalars['Float']['output']; }; +export type CpuPackages = Node & { + __typename?: 'CpuPackages'; + id: Scalars['PrefixedID']['output']; + /** Power draw per package (W) */ + power: Array; + /** Temperature per package (°C) */ + temp: Array; + /** Total CPU package power draw (W) */ + totalPower: Scalars['Float']['output']; +}; + export type CpuUtilization = Node & { __typename?: 'CpuUtilization'; /** CPU load for each core */ @@ -869,6 +880,7 @@ export type InfoCpu = Node & { manufacturer?: Maybe; /** CPU model */ model?: Maybe; + packages: CpuPackages; /** Number of physical processors */ processors?: Maybe; /** CPU revision */ @@ -885,6 +897,8 @@ export type InfoCpu = Node & { stepping?: Maybe; /** Number of CPU threads */ threads?: Maybe; + /** Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]] */ + topology: Array>>; /** CPU vendor */ vendor?: Maybe; /** CPU voltage */ @@ -1531,14 +1545,14 @@ export type PackageVersions = { nginx?: Maybe; /** Node.js version */ node?: Maybe; + /** nodemon version */ + nodemon?: Maybe; /** npm version */ npm?: Maybe; /** OpenSSL version */ openssl?: Maybe; /** PHP version */ php?: Maybe; - /** pm2 version */ - pm2?: Maybe; }; export type ParityCheck = { @@ -2053,6 +2067,7 @@ export type Subscription = { parityHistorySubscription: ParityCheck; serversSubscription: Server; systemMetricsCpu: CpuUtilization; + systemMetricsCpuTelemetry: CpuPackages; systemMetricsMemory: MemoryUtilization; upsUpdates: UpsDevice; }; diff --git a/api/src/unraid-api/cli/logs.command.ts b/api/src/unraid-api/cli/logs.command.ts index c15d8e25aa..0e5d7085fe 100644 --- a/api/src/unraid-api/cli/logs.command.ts +++ b/api/src/unraid-api/cli/logs.command.ts @@ -1,6 +1,6 @@ import { Command, CommandRunner, Option } from 'nest-commander'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; +import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; interface LogsOptions { lines: number; @@ -8,7 +8,7 @@ interface LogsOptions { @Command({ name: 'logs', description: 'View logs' }) export class LogsCommand extends CommandRunner { - constructor(private readonly pm2: PM2Service) { + constructor(private readonly nodemon: NodemonService) { super(); } @@ -20,13 +20,6 @@ export class LogsCommand extends CommandRunner { async run(_: string[], options?: LogsOptions): Promise { const lines = options?.lines ?? 100; - await this.pm2.run( - { tag: 'PM2 Logs', stdio: 'inherit' }, - 'logs', - 'unraid-api', - '--lines', - lines.toString(), - '--raw' - ); + await this.nodemon.logs(lines); } } diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts new file mode 100644 index 0000000000..7ee7dae84b --- /dev/null +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -0,0 +1,99 @@ +import { createWriteStream } from 'node:fs'; +import * as fs from 'node:fs/promises'; + +import { execa } from 'execa'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { fileExists } from '@app/core/utils/files/file-exists.js'; +import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; + +vi.mock('node:fs', () => ({ + createWriteStream: vi.fn(() => ({ pipe: vi.fn() })), +})); +vi.mock('node:fs/promises'); +vi.mock('execa', () => ({ execa: vi.fn() })); +vi.mock('@app/core/utils/files/file-exists.js', () => ({ + fileExists: vi.fn().mockResolvedValue(false), +})); +vi.mock('@app/environment.js', () => ({ + NODEMON_CONFIG_PATH: '/etc/unraid-api/nodemon.json', + NODEMON_PATH: '/usr/bin/nodemon', + NODEMON_PID_PATH: '/var/run/unraid-api/nodemon.pid', + PATHS_LOGS_DIR: '/var/log/unraid-api', + PATHS_LOGS_FILE: '/var/log/graphql-api.log', + UNRAID_API_CWD: '/usr/local/unraid-api', +})); + +describe('NodemonService', () => { + const logger = { + trace: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + log: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + } as unknown as NodemonService['logger']; + + const mockMkdir = vi.mocked(fs.mkdir); + const mockWriteFile = vi.mocked(fs.writeFile); + const mockRm = vi.mocked(fs.rm); + + beforeEach(() => { + vi.clearAllMocks(); + mockMkdir.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined as unknown as void); + mockRm.mockResolvedValue(undefined as unknown as void); + vi.mocked(fileExists).mockResolvedValue(false); + }); + + it('ensures directories needed by nodemon exist', async () => { + const service = new NodemonService(logger); + + await service.ensureNodemonDependencies(); + + expect(mockMkdir).toHaveBeenCalledWith('/var/log/unraid-api', { recursive: true }); + expect(mockMkdir).toHaveBeenCalledWith('/var/run/unraid-api', { recursive: true }); + }); + + it('starts nodemon and writes pid file', async () => { + const service = new NodemonService(logger); + const stdout = { pipe: vi.fn() }; + const stderr = { pipe: vi.fn() }; + const unref = vi.fn(); + vi.mocked(execa).mockReturnValue({ + pid: 123, + stdout, + stderr, + unref, + } as unknown as ReturnType); + + await service.start({ env: { LOG_LEVEL: 'DEBUG' } }); + + expect(execa).toHaveBeenCalledWith( + '/usr/bin/nodemon', + ['--config', '/etc/unraid-api/nodemon.json', '--quiet'], + { + cwd: '/usr/local/unraid-api', + env: expect.objectContaining({ LOG_LEVEL: 'DEBUG' }), + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + expect(createWriteStream).toHaveBeenCalledWith('/var/log/graphql-api.log', { flags: 'a' }); + expect(stdout.pipe).toHaveBeenCalled(); + expect(stderr.pipe).toHaveBeenCalled(); + expect(unref).toHaveBeenCalled(); + expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '123'); + expect(logger.info).toHaveBeenCalledWith('Started nodemon (pid 123)'); + }); + + it('returns not running when pid file is missing', async () => { + const service = new NodemonService(logger); + vi.mocked(fileExists).mockResolvedValue(false); + + const result = await service.status(); + + expect(result).toBe(false); + expect(logger.info).toHaveBeenCalledWith('unraid-api is not running (no pid file).'); + }); +}); diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts new file mode 100644 index 0000000000..1671e45263 --- /dev/null +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -0,0 +1,133 @@ +import { Injectable } from '@nestjs/common'; +import { createWriteStream } from 'node:fs'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { execa } from 'execa'; + +import { fileExists } from '@app/core/utils/files/file-exists.js'; +import { + NODEMON_CONFIG_PATH, + NODEMON_PATH, + NODEMON_PID_PATH, + PATHS_LOGS_DIR, + PATHS_LOGS_FILE, + UNRAID_API_CWD, +} from '@app/environment.js'; +import { LogService } from '@app/unraid-api/cli/log.service.js'; + +type StartOptions = { + env?: Record; +}; + +type StopOptions = { + /** When true, uses SIGKILL instead of SIGTERM */ + force?: boolean; + /** Suppress warnings when there is no pid file */ + quiet?: boolean; +}; + +@Injectable() +export class NodemonService { + constructor(private readonly logger: LogService) {} + + async ensureNodemonDependencies() { + try { + await mkdir(PATHS_LOGS_DIR, { recursive: true }); + await mkdir(dirname(NODEMON_PID_PATH), { recursive: true }); + } catch (error) { + this.logger.error( + `Failed to fully ensure nodemon dependencies: ${error instanceof Error ? error.message : error}` + ); + } + } + + private async getStoredPid(): Promise { + if (!(await fileExists(NODEMON_PID_PATH))) return null; + const contents = (await readFile(NODEMON_PID_PATH, 'utf-8')).trim(); + const pid = Number.parseInt(contents, 10); + return Number.isNaN(pid) ? null : pid; + } + + private async isPidRunning(pid: number): Promise { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + } + + async start(options: StartOptions = {}) { + await this.ensureNodemonDependencies(); + await this.stop({ quiet: true }); + + const env = { ...process.env, ...options.env } as Record; + const logStream = createWriteStream(PATHS_LOGS_FILE, { flags: 'a' }); + + const nodemonProcess = execa(NODEMON_PATH, ['--config', NODEMON_CONFIG_PATH, '--quiet'], { + cwd: UNRAID_API_CWD, + env, + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + nodemonProcess.stdout?.pipe(logStream); + nodemonProcess.stderr?.pipe(logStream); + nodemonProcess.unref(); + + if (nodemonProcess.pid) { + await writeFile(NODEMON_PID_PATH, `${nodemonProcess.pid}`); + this.logger.info(`Started nodemon (pid ${nodemonProcess.pid})`); + } else { + this.logger.error('Failed to determine nodemon pid.'); + } + } + + async stop(options: StopOptions = {}) { + const pid = await this.getStoredPid(); + if (!pid) { + if (!options.quiet) { + this.logger.warn('No nodemon pid file found. Nothing to stop.'); + } + return; + } + + const signal: NodeJS.Signals = options.force ? 'SIGKILL' : 'SIGTERM'; + try { + process.kill(pid, signal); + this.logger.trace(`Sent ${signal} to nodemon (pid ${pid})`); + } catch (error) { + this.logger.error(`Failed to stop nodemon (pid ${pid}): ${error}`); + } finally { + await rm(NODEMON_PID_PATH, { force: true }); + } + } + + async restart(options: StartOptions = {}) { + await this.stop({ quiet: true }); + await this.start(options); + } + + async status(): Promise { + const pid = await this.getStoredPid(); + if (!pid) { + this.logger.info('unraid-api is not running (no pid file).'); + return false; + } + + const running = await this.isPidRunning(pid); + if (running) { + this.logger.info(`unraid-api is running under nodemon (pid ${pid}).`); + } else { + this.logger.warn(`Found nodemon pid file (${pid}) but the process is not running.`); + await rm(NODEMON_PID_PATH, { force: true }); + } + return running; + } + + async logs(lines = 100) { + const { stdout } = await execa('tail', ['-n', `${lines}`, PATHS_LOGS_FILE]); + this.logger.log(stdout); + } +} diff --git a/api/src/unraid-api/cli/pm2.service.spec.ts b/api/src/unraid-api/cli/pm2.service.spec.ts deleted file mode 100644 index 8c16cd5188..0000000000 --- a/api/src/unraid-api/cli/pm2.service.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import * as fs from 'node:fs/promises'; - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { LogService } from '@app/unraid-api/cli/log.service.js'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; - -vi.mock('node:fs/promises'); -vi.mock('execa'); -vi.mock('@app/core/utils/files/file-exists.js', () => ({ - fileExists: vi.fn().mockResolvedValue(false), -})); -vi.mock('@app/environment.js', () => ({ - PATHS_LOGS_DIR: '/var/log/unraid-api', - PM2_HOME: '/var/log/.pm2', - PM2_PATH: '/path/to/pm2', - ECOSYSTEM_PATH: '/path/to/ecosystem.config.json', - SUPPRESS_LOGS: false, - LOG_LEVEL: 'info', -})); - -describe('PM2Service', () => { - let pm2Service: PM2Service; - let logService: LogService; - const mockMkdir = vi.mocked(fs.mkdir); - - beforeEach(() => { - vi.clearAllMocks(); - logService = { - trace: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - log: vi.fn(), - info: vi.fn(), - debug: vi.fn(), - } as unknown as LogService; - pm2Service = new PM2Service(logService); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('ensurePm2Dependencies', () => { - it('should create logs directory and log that PM2 will handle its own directory', async () => { - mockMkdir.mockResolvedValue(undefined); - - await pm2Service.ensurePm2Dependencies(); - - expect(mockMkdir).toHaveBeenCalledWith('/var/log/unraid-api', { recursive: true }); - expect(mockMkdir).toHaveBeenCalledTimes(1); // Only logs directory, not PM2_HOME - expect(logService.trace).toHaveBeenCalledWith( - 'PM2_HOME will be created at /var/log/.pm2 when PM2 daemon starts' - ); - }); - - it('should log error but not throw when logs directory creation fails', async () => { - mockMkdir.mockRejectedValue(new Error('Disk full')); - - await expect(pm2Service.ensurePm2Dependencies()).resolves.not.toThrow(); - - expect(logService.error).toHaveBeenCalledWith( - expect.stringContaining('Failed to fully ensure PM2 dependencies: Disk full') - ); - }); - - it('should handle mkdir with recursive flag for nested logs path', async () => { - mockMkdir.mockResolvedValue(undefined); - - await pm2Service.ensurePm2Dependencies(); - - expect(mockMkdir).toHaveBeenCalledWith('/var/log/unraid-api', { recursive: true }); - expect(mockMkdir).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/api/src/unraid-api/cli/pm2.service.ts b/api/src/unraid-api/cli/pm2.service.ts deleted file mode 100644 index b16a4a40b1..0000000000 --- a/api/src/unraid-api/cli/pm2.service.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { mkdir, rm } from 'node:fs/promises'; -import { join } from 'node:path'; - -import type { Options, Result, ResultPromise } from 'execa'; -import { execa, ExecaError } from 'execa'; - -import { fileExists } from '@app/core/utils/files/file-exists.js'; -import { PATHS_LOGS_DIR, PM2_HOME, PM2_PATH } from '@app/environment.js'; -import { LogService } from '@app/unraid-api/cli/log.service.js'; - -type CmdContext = Options & { - /** A tag for logging & debugging purposes. Should represent the operation being performed. */ - tag: string; - /** Default: false. - * - * When true, results will not be automatically handled and logged. - * The caller must handle desired effects, such as logging, error handling, etc. - */ - raw?: boolean; -}; - -@Injectable() -export class PM2Service { - constructor(private readonly logger: LogService) {} - - // Type Overload: if raw is true, return an execa ResultPromise (which is a Promise with extra properties) - /** - * Executes a PM2 command with the specified context and arguments. - * Handles logging automatically (stdout -> trace, stderr -> error), unless the `raw` flag is - * set to true, in which case the caller must handle desired effects. - * - * @param context - Execa Options for command execution, such as a unique tag for logging - * and whether the result should be handled raw. - * @param args - The arguments to pass to the PM2 command. - * @returns ResultPromise\<@param context\> When raw is true - * @returns Promise\ When raw is false - */ - run(context: T & { raw: true }, ...args: string[]): ResultPromise; - - run(context: CmdContext & { raw?: false }, ...args: string[]): Promise; - - async run(context: CmdContext, ...args: string[]) { - const { tag, raw, ...execOptions } = context; - // Default to true to match execa's default behavior - execOptions.extendEnv ??= true; - execOptions.shell ??= 'bash'; - - // Ensure /usr/local/bin is in PATH for Node.js - const currentPath = execOptions.env?.PATH || process.env.PATH || '/usr/bin:/bin:/usr/sbin:/sbin'; - const needsPathUpdate = !currentPath.includes('/usr/local/bin'); - const finalPath = needsPathUpdate ? `/usr/local/bin:${currentPath}` : currentPath; - - // Always ensure PM2_HOME is set in the environment for every PM2 command - execOptions.env = { - ...execOptions.env, - PM2_HOME, - ...(needsPathUpdate && { PATH: finalPath }), - }; - - const pm2Args = args.some((arg) => arg === '--no-color') ? args : ['--no-color', ...args]; - const runCommand = () => execa(PM2_PATH, pm2Args, execOptions satisfies Options); - if (raw) { - return runCommand(); - } - return runCommand() - .then((result) => { - this.logger.trace(result.stdout); - return result; - }) - .catch((result: Result) => { - this.logger.error(`PM2 error occurred from tag "${tag}": ${result.stdout}\n`); - return result; - }); - } - - /** - * Deletes the PM2 dump file. - * - * This method removes the PM2 dump file located at `~/.pm2/dump.pm2` by default. - * It logs a message indicating that the PM2 dump has been cleared. - * - * @returns A promise that resolves once the dump file is removed. - */ - async deleteDump(dumpFile = join(PM2_HOME, 'dump.pm2')) { - await rm(dumpFile, { force: true }); - this.logger.trace('PM2 dump cleared.'); - } - - async forceKillPm2Daemon() { - try { - // Find all PM2 daemon processes and kill them - const pids = (await execa('pgrep', ['-i', 'PM2'])).stdout.split('\n').filter(Boolean); - if (pids.length > 0) { - await execa('kill', ['-9', ...pids]); - this.logger.trace(`Killed PM2 daemon processes: ${pids.join(', ')}`); - } - } catch (err) { - if (err instanceof ExecaError && err.exitCode === 1) { - this.logger.trace('No PM2 daemon processes found.'); - } else { - this.logger.error(`Error force killing PM2 daemon: ${err}`); - } - } - } - - async deletePm2Home() { - if ((await fileExists(PM2_HOME)) && (await fileExists(join(PM2_HOME, 'pm2.log')))) { - await rm(PM2_HOME, { recursive: true, force: true }); - this.logger.trace('PM2 home directory cleared.'); - } else { - this.logger.trace('PM2 home directory does not exist.'); - } - } - - /** - * Ensures that the dependencies necessary for PM2 to start and operate are present. - * Creates PM2_HOME directory with proper permissions if it doesn't exist. - */ - async ensurePm2Dependencies() { - try { - // Create logs directory - await mkdir(PATHS_LOGS_DIR, { recursive: true }); - - // PM2 automatically creates and manages its home directory when the daemon starts - this.logger.trace(`PM2_HOME will be created at ${PM2_HOME} when PM2 daemon starts`); - } catch (error) { - // Log error but don't throw - let PM2 fail with its own error messages if the setup is incomplete - this.logger.error( - `Failed to fully ensure PM2 dependencies: ${error instanceof Error ? error.message : error}. PM2 may encounter issues during operation.` - ); - } - } -} diff --git a/api/src/unraid-api/cli/report.command.ts b/api/src/unraid-api/cli/report.command.ts index 1e03dea5c5..49188cf77c 100644 --- a/api/src/unraid-api/cli/report.command.ts +++ b/api/src/unraid-api/cli/report.command.ts @@ -33,9 +33,9 @@ export class ReportCommand extends CommandRunner { async report(): Promise { try { // Check if API is running - const { isUnraidApiRunning } = await import('@app/core/utils/pm2/unraid-api-running.js'); + const { isUnraidApiRunning } = await import('@app/core/utils/process/unraid-api-running.js'); const apiRunning = await isUnraidApiRunning().catch((err) => { - this.logger.debug('failed to get PM2 state with error: ' + err); + this.logger.debug('failed to check nodemon state with error: ' + err); return false; }); diff --git a/api/src/unraid-api/cli/restart.command.ts b/api/src/unraid-api/cli/restart.command.ts index 66d54a513e..166162fa6b 100644 --- a/api/src/unraid-api/cli/restart.command.ts +++ b/api/src/unraid-api/cli/restart.command.ts @@ -2,9 +2,9 @@ import { Command, CommandRunner, Option } from 'nest-commander'; import type { LogLevel } from '@app/core/log.js'; import { levels } from '@app/core/log.js'; -import { ECOSYSTEM_PATH, LOG_LEVEL } from '@app/environment.js'; +import { LOG_LEVEL } 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 { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; export interface LogLevelOptions { logLevel?: LogLevel; @@ -22,7 +22,7 @@ export function parseLogLevelOption(val: string, allowedLevels: string[] = [...l export class RestartCommand extends CommandRunner { constructor( private readonly logger: LogService, - private readonly pm2: PM2Service + private readonly nodemon: NodemonService ) { super(); } @@ -30,23 +30,9 @@ export class RestartCommand extends CommandRunner { async run(_?: string[], options: LogLevelOptions = {}): Promise { try { this.logger.info('Restarting the Unraid API...'); - const env = { LOG_LEVEL: options.logLevel }; - const { stderr, stdout } = await this.pm2.run( - { tag: 'PM2 Restart', raw: true, extendEnv: true, env }, - 'restart', - ECOSYSTEM_PATH, - '--update-env', - '--mini-list' - ); - - if (stderr) { - this.logger.error(stderr.toString()); - process.exit(1); - } else if (stdout) { - this.logger.info(stdout.toString()); - } else { - this.logger.info('Unraid API restarted'); - } + const env = { LOG_LEVEL: options.logLevel?.toUpperCase() }; + await this.nodemon.restart({ env }); + this.logger.info('Unraid API restarted'); } catch (error) { if (error instanceof Error) { this.logger.error(error.message); diff --git a/api/src/unraid-api/cli/start.command.ts b/api/src/unraid-api/cli/start.command.ts index 64c7d890d0..61660612c9 100644 --- a/api/src/unraid-api/cli/start.command.ts +++ b/api/src/unraid-api/cli/start.command.ts @@ -3,46 +3,23 @@ import { Command, CommandRunner, Option } from 'nest-commander'; import type { LogLevel } from '@app/core/log.js'; import type { LogLevelOptions } from '@app/unraid-api/cli/restart.command.js'; import { levels } from '@app/core/log.js'; -import { ECOSYSTEM_PATH, LOG_LEVEL } from '@app/environment.js'; +import { LOG_LEVEL } 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 { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; import { parseLogLevelOption } from '@app/unraid-api/cli/restart.command.js'; @Command({ name: 'start', description: 'Start the Unraid API' }) export class StartCommand extends CommandRunner { constructor( private readonly logger: LogService, - private readonly pm2: PM2Service + private readonly nodemon: NodemonService ) { super(); } - async cleanupPM2State() { - await this.pm2.ensurePm2Dependencies(); - await this.pm2.run({ tag: 'PM2 Stop' }, 'stop', ECOSYSTEM_PATH); - await this.pm2.run({ tag: 'PM2 Update' }, 'update'); - await this.pm2.deleteDump(); - await this.pm2.run({ tag: 'PM2 Delete' }, 'delete', ECOSYSTEM_PATH); - } - async run(_: string[], options: LogLevelOptions): Promise { this.logger.info('Starting the Unraid API'); - await this.cleanupPM2State(); - const env = { LOG_LEVEL: options.logLevel }; - const { stderr, stdout } = await this.pm2.run( - { tag: 'PM2 Start', raw: true, extendEnv: true, env }, - 'start', - ECOSYSTEM_PATH, - '--update-env', - '--mini-list' - ); - if (stdout) { - this.logger.log(stdout.toString()); - } - if (stderr) { - this.logger.error(stderr.toString()); - process.exit(1); - } + await this.nodemon.start({ env: { LOG_LEVEL: options.logLevel?.toUpperCase() } }); } @Option({ diff --git a/api/src/unraid-api/cli/status.command.ts b/api/src/unraid-api/cli/status.command.ts index 6e1b6b6e2e..489198e3b9 100644 --- a/api/src/unraid-api/cli/status.command.ts +++ b/api/src/unraid-api/cli/status.command.ts @@ -1,18 +1,13 @@ import { Command, CommandRunner } from 'nest-commander'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; +import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; @Command({ name: 'status', description: 'Check status of unraid-api service' }) export class StatusCommand extends CommandRunner { - constructor(private readonly pm2: PM2Service) { + constructor(private readonly nodemon: NodemonService) { super(); } async run(): Promise { - await this.pm2.run( - { tag: 'PM2 Status', stdio: 'inherit', raw: true }, - 'status', - 'unraid-api', - '--mini-list' - ); + await this.nodemon.status(); } } diff --git a/api/src/unraid-api/cli/stop.command.ts b/api/src/unraid-api/cli/stop.command.ts index 995dd07437..376c89c6e2 100644 --- a/api/src/unraid-api/cli/stop.command.ts +++ b/api/src/unraid-api/cli/stop.command.ts @@ -1,41 +1,28 @@ import { Command, CommandRunner, Option } from 'nest-commander'; -import { ECOSYSTEM_PATH } from '@app/environment.js'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; +import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; interface StopCommandOptions { - delete: boolean; + force: boolean; } @Command({ name: 'stop', description: 'Stop the Unraid API', }) export class StopCommand extends CommandRunner { - constructor(private readonly pm2: PM2Service) { + constructor(private readonly nodemon: NodemonService) { super(); } @Option({ - flags: '-d, --delete', - description: 'Delete the PM2 home directory', + flags: '-f, --force', + description: 'Forcefully stop the API process', }) - parseDelete(): boolean { + parseForce(): boolean { return true; } - async run(_: string[], options: StopCommandOptions = { delete: false }) { - if (options.delete) { - await this.pm2.run({ tag: 'PM2 Kill', stdio: 'inherit' }, 'kill', '--no-autorestart'); - await this.pm2.forceKillPm2Daemon(); - await this.pm2.deletePm2Home(); - } else { - await this.pm2.run( - { tag: 'PM2 Delete', stdio: 'inherit' }, - 'delete', - ECOSYSTEM_PATH, - '--no-autorestart', - '--mini-list' - ); - } + async run(_: string[], options: StopCommandOptions = { force: false }) { + await this.nodemon.stop({ force: options.force }); } } diff --git a/api/src/unraid-api/graph/resolvers/info/versions/versions.model.ts b/api/src/unraid-api/graph/resolvers/info/versions/versions.model.ts index dd6fe5d880..2080cbbb91 100644 --- a/api/src/unraid-api/graph/resolvers/info/versions/versions.model.ts +++ b/api/src/unraid-api/graph/resolvers/info/versions/versions.model.ts @@ -25,8 +25,8 @@ export class PackageVersions { @Field(() => String, { nullable: true, description: 'npm version' }) npm?: string; - @Field(() => String, { nullable: true, description: 'pm2 version' }) - pm2?: string; + @Field(() => String, { nullable: true, description: 'nodemon version' }) + nodemon?: string; @Field(() => String, { nullable: true, description: 'Git version' }) git?: string; diff --git a/api/src/unraid-api/graph/resolvers/info/versions/versions.resolver.ts b/api/src/unraid-api/graph/resolvers/info/versions/versions.resolver.ts index a711a17dd1..836122b3b5 100644 --- a/api/src/unraid-api/graph/resolvers/info/versions/versions.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/info/versions/versions.resolver.ts @@ -3,6 +3,7 @@ import { ResolveField, Resolver } from '@nestjs/graphql'; import { versions } from 'systeminformation'; +import { getPackageJson } from '@app/environment.js'; import { CoreVersions, InfoVersions, @@ -34,7 +35,7 @@ export class VersionsResolver { openssl: softwareVersions.openssl, node: softwareVersions.node, npm: softwareVersions.npm, - pm2: softwareVersions.pm2, + nodemon: getPackageJson().dependencies?.nodemon, git: softwareVersions.git, nginx: softwareVersions.nginx, php: softwareVersions.php, diff --git a/api/src/unraid-api/main.ts b/api/src/unraid-api/main.ts index 4b753abfaa..cc07b5b639 100644 --- a/api/src/unraid-api/main.ts +++ b/api/src/unraid-api/main.ts @@ -140,16 +140,6 @@ export async function bootstrapNestServer(): Promise { apiLogger.info('Server listening on %s', result); } - // This 'ready' signal tells pm2 that the api has started. - // PM2 documents this as Graceful Start or Clean Restart. - // See https://pm2.keymetrics.io/docs/usage/signals-clean-restart/ - if (process.send) { - process.send('ready'); - } else { - apiLogger.warn( - 'Warning: process.send is unavailable. This will affect IPC communication with PM2.' - ); - } apiLogger.info('Nest Server is now listening'); return app; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8db98eb3a4..a9a59a9b9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -262,6 +262,9 @@ importers: node-window-polyfill: specifier: 1.0.4 version: 1.0.4 + nodemon: + specifier: 3.1.10 + version: 3.1.10 openid-client: specifier: 6.6.4 version: 6.6.4 @@ -286,9 +289,6 @@ importers: pino-pretty: specifier: 13.1.1 version: 13.1.1 - pm2: - specifier: 6.0.8 - version: 6.0.8 reflect-metadata: specifier: ^0.1.14 version: 0.1.14 @@ -458,9 +458,6 @@ importers: jiti: specifier: 2.5.1 version: 2.5.1 - nodemon: - specifier: 3.1.10 - version: 3.1.10 prettier: specifier: 3.6.2 version: 3.6.2 @@ -4028,20 +4025,6 @@ packages: resolution: {integrity: sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@pm2/agent@2.1.1': - resolution: {integrity: sha512-0V9ckHWd/HSC8BgAbZSoq8KXUG81X97nSkAxmhKDhmF8vanyaoc1YXwc2KVkbWz82Rg4gjd2n9qiT3i7bdvGrQ==} - - '@pm2/io@6.1.0': - resolution: {integrity: sha512-IxHuYURa3+FQ6BKePlgChZkqABUKFYH6Bwbw7V/pWU1pP6iR1sCI26l7P9ThUEB385ruZn/tZS3CXDUF5IA1NQ==} - engines: {node: '>=6.0'} - - '@pm2/js-api@0.8.0': - resolution: {integrity: sha512-nmWzrA/BQZik3VBz+npRcNIu01kdBhWL0mxKmP1ciF/gTcujPTQqt027N9fc1pK9ERM8RipFhymw7RcmCyOEYA==} - engines: {node: '>=4.0'} - - '@pm2/pm2-version-check@1.0.4': - resolution: {integrity: sha512-SXsM27SGH3yTWKc2fKR4SYNxsmnvuBQ9dd6QHtEWmiZ/VqaOYPAIlS8+vMcn27YLtAEBGvNRSh3TPNvtjZgfqA==} - '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -4764,9 +4747,6 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} - '@tootallnate/quickjs-emscripten@0.23.0': - resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} - '@ts-morph/common@0.25.0': resolution: {integrity: sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==} @@ -5771,16 +5751,6 @@ packages: alien-signals@2.0.5: resolution: {integrity: sha512-PdJB6+06nUNAClInE3Dweq7/2xVAYM64vvvS1IHVHSJmgeOtEdrAGyp7Z2oJtYm0B342/Exd2NT0uMJaThcjLQ==} - amp-message@0.1.2: - resolution: {integrity: sha512-JqutcFwoU1+jhv7ArgW38bqrE+LQdcRv4NxNw0mp0JHQyB6tXesWRjtYKlDgHRY2o3JE5UTaBGUK8kSWUdxWUg==} - - amp@0.3.1: - resolution: {integrity: sha512-OwIuC4yZaRogHKiuU5WlMR5Xk/jAcpPtawWL05Gj8Lvm2F6mwoJt4O/bHI+DHwG79vWd+8OFYM4/BzYqyRd3qw==} - - ansi-colors@4.1.3: - resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} - engines: {node: '>=6'} - ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -5824,10 +5794,6 @@ packages: ansi_up@6.0.6: resolution: {integrity: sha512-yIa1x3Ecf8jWP4UWEunNjqNX6gzE4vg2gGz+xqRGY+TBSucnYp6RRdPV4brmtg6bQ1ljD48mZ5iGSEj7QEpRKA==} - ansis@4.0.0-node10: - resolution: {integrity: sha512-BRrU0Bo1X9dFGw6KgGz6hWrqQuOlVEDOzkb0QSLZY9sXHqA7pNj7yHPVJRz7y/rj4EOJ3d/D5uxH+ee9leYgsg==} - engines: {node: '>=10'} - ansis@4.1.0: resolution: {integrity: sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w==} engines: {node: '>=14'} @@ -5935,10 +5901,6 @@ packages: resolution: {integrity: sha512-ZtfIlyTCmnAXPCQo4mSDtFsHL7L3q0sJfpVYPmy5uYPjs+fynzOuc1Cg6yQ9fF6h61RjEWtOlRFwV1Kc80Qs6A==} engines: {node: '>=4'} - ast-types@0.13.4: - resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} - engines: {node: '>=4'} - ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} @@ -5967,9 +5929,6 @@ packages: async@1.5.2: resolution: {integrity: sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==} - async@2.6.4: - resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==} - async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -6055,10 +6014,6 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} - basic-ftp@5.0.5: - resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} - engines: {node: '>=10.0.0'} - bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} @@ -6082,17 +6037,9 @@ packages: blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} - blessed@0.1.81: - resolution: {integrity: sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==} - engines: {node: '>= 0.8.0'} - hasBin: true - blob-to-buffer@1.2.9: resolution: {integrity: sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA==} - bodec@0.1.0: - resolution: {integrity: sha512-Ylo+MAo5BDUq1KA3f3R/MFhh+g8cnHmo8bz3YPGhI1znrMaf77ol1sfvYJzsw3nTE+Y2GryfDxBaR+AqpAkEHQ==} - body-parser@1.20.3: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -6273,9 +6220,6 @@ packages: chardet@2.1.0: resolution: {integrity: sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==} - charm@0.1.2: - resolution: {integrity: sha512-syedaZ9cPe7r3hoQA9twWYKu5AIyCswN5+szkmPBe9ccdLrj4bYaCnLVPTLd2kgVRc7+zoX4tyPgRnFKCj5YjQ==} - check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} @@ -6330,10 +6274,6 @@ packages: resolution: {integrity: sha512-IqLQi4lO0nIB4tcdTpN4LCB9FI3uqrJZK7RC515EnhZ6qBaglkIgICb1wjeAqpdoOabm1+SuQtkXIPdYC93jhQ==} engines: {node: '>= 0.2.0'} - cli-tableau@2.0.1: - resolution: {integrity: sha512-he+WTicka9cl0Fg/y+YyxcN6/bfQ/1O3QmgxRXDhABKqLzvoOSM4fMzp39uMyLBulAFuywD2N7UaoQE7WaADxQ==} - engines: {node: '>=8.10.0'} - cli-truncate@4.0.0: resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} engines: {node: '>=18'} @@ -6439,9 +6379,6 @@ packages: resolution: {integrity: sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==} engines: {node: '>=20'} - commander@2.15.1: - resolution: {integrity: sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==} - commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -6630,9 +6567,6 @@ packages: resolution: {integrity: sha512-ciiYNLfSlF9MrDqnbMdRWFiA6oizSF7kA1osPP9lRzNu0Uu+AWog1UKy7SkckiDY2irrNjeO6qLyKnXC8oxmrw==} engines: {node: '>=18.x'} - croner@4.1.97: - resolution: {integrity: sha512-/f6gpQuxDaqXu+1kwQYSckUglPaOrHdbIlBAu0YuW8/Cdb45XwXYNUBXg3r/9Mo6n540Kn/smKcZWko5x99KrQ==} - croner@9.1.0: resolution: {integrity: sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g==} engines: {node: '>=18.0'} @@ -6724,9 +6658,6 @@ packages: csv-parse@5.6.0: resolution: {integrity: sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==} - culvert@0.1.2: - resolution: {integrity: sha512-yi1x3EAWKjQTreYWeSd98431AV+IEE0qoDyOoaHJ7KJ21gv6HtBXHVLX74opVSGqcR8/AbjJBHAHpcOy2bj5Gg==} - d@1.0.2: resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} engines: {node: '>=0.12'} @@ -6735,10 +6666,6 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} - data-uri-to-buffer@6.0.2: - resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} - engines: {node: '>= 14'} - data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -6764,15 +6691,9 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} - dayjs@1.11.13: - resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} - dayjs@1.11.14: resolution: {integrity: sha512-E8fIdSxUlyqSA8XYGnNa3IkIzxtEmFjI+JU/6ic0P1zmSqyL6HyG5jHnpPjRguDNiaHLpfvHKWFiohNsJLqcJQ==} - dayjs@1.8.36: - resolution: {integrity: sha512-3VmRXEtw7RZKAf+4Tv1Ym9AGeo8r8+CjDi26x+7SYQil1UqtqdaokhzoEJohqlzt0m5kacJSDhJQkG/LWhpRBw==} - db0@0.3.2: resolution: {integrity: sha512-xzWNQ6jk/+NtdfLyXEipbX55dmDSeteLFt/ayF+wZUU5bzKgmrDOxmInUTbyVRp46YwnJdkDA1KhB7WIXFofJw==} peerDependencies: @@ -6819,15 +6740,6 @@ packages: supports-color: optional: true - debug@4.3.7: - resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -6911,10 +6823,6 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} - degenerator@5.0.1: - resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} - engines: {node: '>= 14'} - delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -7151,10 +7059,6 @@ packages: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} - enquirer@2.3.6: - resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==} - engines: {node: '>=8.6'} - entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -7407,11 +7311,6 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - escodegen@2.1.0: - resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} - engines: {node: '>=6.0'} - hasBin: true - eslint-config-prettier@10.1.8: resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true @@ -7602,9 +7501,6 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} - eventemitter2@5.0.1: - resolution: {integrity: sha512-5EM1GHXycJBS6mauYAbVKT1cVs7POKWb2NXD4Vyt8dDqeZa7LaDK1/sjtL+Zb0lzTpSNil4596Dyu97hz37QLg==} - eventemitter2@6.4.9: resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} @@ -7674,9 +7570,6 @@ packages: resolution: {integrity: sha512-FuoE1qtbJ4bBVvv94CC7s0oTnKUGvQs+Rjf1L2SJFfS+HTVVjhPFtehPdQ0JiGPqVNfSSZvL5yzHHQq2Z4WNhQ==} engines: {node: ^12.20 || >= 14.13} - extrareqp2@1.0.0: - resolution: {integrity: sha512-Gum0g1QYb6wpPJCVypWP3bbIuaibcFiJcpuPM10YSXp/tzqi84x9PJageob+eN4xVRIOto4wjSGNLyMD54D2xA==} - fast-check@4.2.0: resolution: {integrity: sha512-buxrKEaSseOwFjt6K1REcGMeFOrb0wk3cXifeMAG8yahcE9kV20PjQn1OdzPGL6OBFTbYXfjleNBARf/aCfV1A==} engines: {node: '>=12.17.0'} @@ -7700,9 +7593,6 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} - fast-json-patch@3.1.1: - resolution: {integrity: sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -7756,9 +7646,6 @@ packages: fbjs@3.0.5: resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} - fclone@1.0.11: - resolution: {integrity: sha512-GDqVQezKzRABdeqflsgMr7ktzgF9CyS+p2oe0jJqUY6izSSbhPIQJDpoU4PtGcD7VPM9xh/dVrTu6z1nwgmEGw==} - fd-package-json@2.0.0: resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} @@ -8001,25 +7888,10 @@ packages: get-tsconfig@4.10.1: resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} - get-uri@6.0.4: - resolution: {integrity: sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==} - engines: {node: '>= 14'} - giget@2.0.0: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true - git-node-fs@1.0.0: - resolution: {integrity: sha512-bLQypt14llVXBg0S0u8q8HmU7g9p3ysH+NvVlae5vILuUvs759665HvmR5+wb04KjHyjFcDRxdYb4kyNnluMUQ==} - peerDependencies: - js-git: ^0.7.8 - peerDependenciesMeta: - js-git: - optional: true - - git-sha1@0.1.2: - resolution: {integrity: sha512-2e/nZezdVlyCopOCYHeW0onkbZg7xP1Ad6pndPy1rCygeRykefUS6r7oA5cJRGEFvseiaz5a/qUHFVX1dd6Isg==} - git-up@8.1.1: resolution: {integrity: sha512-FDenSF3fVqBYSaJoYy1KSc2wosx0gCvKP+c+PRBht7cAaiCeQlBtfBDX9vgnNOHmdePlSFITVcn4pFfcgNvx3g==} @@ -8454,10 +8326,6 @@ packages: resolution: {integrity: sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==} engines: {node: '>=12.22.0'} - ip-address@9.0.5: - resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==} - engines: {node: '>= 12'} - ip@2.0.1: resolution: {integrity: sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==} @@ -8818,9 +8686,6 @@ packages: resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} engines: {node: '>=14'} - js-git@0.7.8: - resolution: {integrity: sha512-+E5ZH/HeRnoc/LW0AmAyhU+mNcWBzAKE+30+IDMLSLbbK+Tdt02AdkOKq9u15rlJsDEGFqtgckc8ZM59LhhiUA==} - js-stringify@1.0.2: resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==} @@ -8834,9 +8699,6 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true - jsbn@1.1.0: - resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} - jsdom@26.1.0: resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} engines: {node: '>=18'} @@ -9415,11 +9277,6 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - mkdirp@1.0.4: - resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} - engines: {node: '>=10'} - hasBin: true - mkdirp@3.0.1: resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} engines: {node: '>=10'} @@ -9434,9 +9291,6 @@ packages: mocked-exports@0.1.1: resolution: {integrity: sha512-aF7yRQr/Q0O2/4pIXm6PZ5G+jAd7QS4Yu8m+WEeEHGnbo+7mE36CbLSDQiXYV8bVL3NfmdeqPJct0tUlnjVSnA==} - module-details-from-path@1.0.3: - resolution: {integrity: sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==} - motion-dom@12.23.12: resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==} @@ -9516,11 +9370,6 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - needle@2.4.0: - resolution: {integrity: sha512-4Hnwzr3mi5L97hMYeNl8wRW/Onhy4nUKR/lVemJ8gJedxxUyBLm9kkrDColJvoSfwi0jCNhD+xCdOtiGDQiRZg==} - engines: {node: '>= 4.4.x'} - hasBin: true - negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} @@ -9561,10 +9410,6 @@ packages: resolution: {integrity: sha512-Nc3loyVASW59W+8fLDZT1lncpG7llffyZ2o0UQLx/Fr20i7P8oP+lE7+TEcFvXj9IUWU6LjB9P3BH+iFGyp+mg==} engines: {node: ^14.16.0 || >=16.0.0} - netmask@2.0.2: - resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} - engines: {node: '>= 0.4.0'} - next-tick@1.1.0: resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} @@ -9912,14 +9757,6 @@ packages: resolution: {integrity: sha512-lwx6u1CotQYPVju77R+D0vFomni/AqRfqLmqQ8hekklqZ6gAY9rONh7lBQ0uxWMkC2AuX9b2DVAl8To0NyP1JA==} engines: {node: '>=12'} - pac-proxy-agent@7.1.0: - resolution: {integrity: sha512-Z5FnLVVZSnX7WjBg0mhDtydeRZ1xMcATZThjySQUHqr+0ksP8kqaw23fNKkaaN/Z8gwLUs/W7xdl0I75eP2Xyw==} - engines: {node: '>= 14'} - - pac-resolver@7.0.1: - resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} - engines: {node: '>= 14'} - package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -10099,14 +9936,6 @@ packages: engines: {node: '>=0.10'} hasBin: true - pidusage@2.0.21: - resolution: {integrity: sha512-cv3xAQos+pugVX+BfXpHsbyz/dLzX+lr44zNMsYiGxUw+kV5sgQCIcLd1z+0vq+KyC7dJ+/ts2PsfgWfSC3WXA==} - engines: {node: '>=8'} - - pidusage@3.0.2: - resolution: {integrity: sha512-g0VU+y08pKw5M8EZ2rIGiEBaB8wrQMjYGFfW2QVIfyT8V+fq8YFLkvlz4bz5ljvFDJYNFCWT3PWqcRr2FKO81w==} - engines: {node: '>=10'} - pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -10172,29 +10001,6 @@ packages: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} - pm2-axon-rpc@0.7.1: - resolution: {integrity: sha512-FbLvW60w+vEyvMjP/xom2UPhUN/2bVpdtLfKJeYM3gwzYhoTEEChCOICfFzxkxuoEleOlnpjie+n1nue91bDQw==} - engines: {node: '>=5'} - - pm2-axon@4.0.1: - resolution: {integrity: sha512-kES/PeSLS8orT8dR5jMlNl+Yu4Ty3nbvZRmaAtROuVm9nYYGiaoXqqKQqQYzWQzMYWUKHMQTvBlirjE5GIIxqg==} - engines: {node: '>=5'} - - pm2-deploy@1.0.2: - resolution: {integrity: sha512-YJx6RXKrVrWaphEYf++EdOOx9EH18vM8RSZN/P1Y+NokTKqYAca/ejXwVLyiEpNju4HPZEk3Y2uZouwMqUlcgg==} - engines: {node: '>=4.0.0'} - - pm2-multimeter@0.1.2: - resolution: {integrity: sha512-S+wT6XfyKfd7SJIBqRgOctGxaBzUOmVQzTAS+cg04TsEUObJVreha7lvCfX8zzGVr871XwCSnHUU7DQQ5xEsfA==} - - pm2-sysmonit@1.2.8: - resolution: {integrity: sha512-ACOhlONEXdCTVwKieBIQLSi2tQZ8eKinhcr9JpZSUAL8Qy0ajIgRtsLxG/lwPOW3JEKqPyw/UaHmTWhUzpP4kA==} - - pm2@6.0.8: - resolution: {integrity: sha512-y7sO+UuGjfESK/ChRN+efJKAsHrBd95GY2p1GQfjVTtOfFtUfiW0NOuUhP5dN5QTF2F0EWcepgkLqbF32j90Iw==} - engines: {node: '>=16.0.0'} - hasBin: true - portfinder@1.0.35: resolution: {integrity: sha512-73JaFg4NwYNAufDtS5FsFu/PdM49ahJrO1i44aCRsDWju1z5wuGDaqyFUQWR6aJoK2JPDWlaYYAGFNIGTSUHSw==} engines: {node: '>= 10.12'} @@ -10512,9 +10318,6 @@ packages: promise@7.3.1: resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} - promptly@2.2.0: - resolution: {integrity: sha512-aC9j+BZsRSSzEsXBNBwDnAxujdx19HycZoKgRgzWnS8eOHg1asuf9heuLprfbe739zY3IdUQx+Egv6Jn135WHA==} - prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -10539,13 +10342,6 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - proxy-agent@6.4.0: - resolution: {integrity: sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==} - engines: {node: '>= 14'} - - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - pstree.remy@1.1.8: resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} @@ -10662,10 +10458,6 @@ packages: read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} - read@1.0.7: - resolution: {integrity: sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==} - engines: {node: '>=0.8'} - readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -10787,10 +10579,6 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - require-in-the-middle@5.2.0: - resolution: {integrity: sha512-efCx3b+0Z69/LGJmm9Yvi4cqEdxnoGnxYxGxBghkkTTFeXRtTCmmhO0AnAfHz59k957uTSuy8WaHqOs8wbYUWg==} - engines: {node: '>=6'} - require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} @@ -10916,9 +10704,6 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - run-series@1.1.9: - resolution: {integrity: sha512-Arc4hUN896vjkqCYrUXquBFtRZdv1PfLbTYP71efP6butxyQ0kWpiNJyAgsxscmQg1cqvHY32/UCBzXedTpU2g==} - rxjs@6.6.7: resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==} engines: {npm: '>=2.0.0'} @@ -11073,9 +10858,6 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} - shimmer@1.2.1: - resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==} - side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -11146,24 +10928,12 @@ packages: resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} engines: {node: '>=18'} - smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - smob@1.5.0: resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==} snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} - socks-proxy-agent@8.0.5: - resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} - engines: {node: '>= 14'} - - socks@2.8.4: - resolution: {integrity: sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} @@ -11219,9 +10989,6 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - sprintf-js@1.1.2: - resolution: {integrity: sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==} - sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} @@ -11696,9 +11463,6 @@ packages: tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - tslib@1.9.3: - resolution: {integrity: sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==} - tslib@2.4.1: resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==} @@ -11713,19 +11477,12 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - tv4@1.3.0: - resolution: {integrity: sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==} - engines: {node: '>= 0.8.0'} - tw-animate-css@1.3.7: resolution: {integrity: sha512-lvLb3hTIpB5oGsk8JmLoAjeCHV58nKa2zHYn8yWOoG5JJusH3bhJlF2DLAZ/5NmJ+jyH3ssiAx/2KmbhavJy/A==} tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} - tx2@1.0.5: - resolution: {integrity: sha512-sJ24w0y03Md/bxzK4FU8J8JveYYUbSs2FViLJ2D/8bytSiyPRbuE3DyL/9UKYXTZlV3yXq0L8GLlhobTnekCVg==} - type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -12406,10 +12163,6 @@ packages: jsdom: optional: true - vizion@2.2.1: - resolution: {integrity: sha512-sfAcO2yeSU0CSPFI/DmZp3FsFE9T+8913nv1xWBOyzODv13fwkn6Vl7HqxGpkr9F608M+8SuFId3s+BlZqfXww==} - engines: {node: '>=4.0'} - void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -12434,8 +12187,8 @@ packages: vue-component-type-helpers@3.0.6: resolution: {integrity: sha512-6CRM8X7EJqWCJOiKPvSLQG+hJPb/Oy2gyJx3pLjUEhY7PuaCthQu3e0zAGI1lqUBobrrk9IT0K8sG2GsCluxoQ==} - vue-component-type-helpers@3.1.3: - resolution: {integrity: sha512-V1dOD8XYfstOKCnXbWyEJIrhTBMwSyNjv271L1Jlx9ExpNlCSuqOs3OdWrGJ0V544zXufKbcYabi/o+gK8lyfQ==} + vue-component-type-helpers@3.1.4: + resolution: {integrity: sha512-Uws7Ew1OzTTqHW8ZVl/qLl/HB+jf08M0NdFONbVWAx0N4gMLK8yfZDgeB77hDnBmaigWWEn5qP8T9BG59jIeyQ==} vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} @@ -16053,56 +15806,6 @@ snapshots: '@pkgr/core@0.2.7': {} - '@pm2/agent@2.1.1': - dependencies: - async: 3.2.6 - chalk: 3.0.0 - dayjs: 1.8.36 - debug: 4.3.7 - eventemitter2: 5.0.1 - fast-json-patch: 3.1.1 - fclone: 1.0.11 - pm2-axon: 4.0.1 - pm2-axon-rpc: 0.7.1 - proxy-agent: 6.4.0 - semver: 7.5.4 - ws: 7.5.10 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - '@pm2/io@6.1.0': - dependencies: - async: 2.6.4 - debug: 4.3.7 - eventemitter2: 6.4.9 - require-in-the-middle: 5.2.0 - semver: 7.5.4 - shimmer: 1.2.1 - signal-exit: 3.0.7 - tslib: 1.9.3 - transitivePeerDependencies: - - supports-color - - '@pm2/js-api@0.8.0': - dependencies: - async: 2.6.4 - debug: 4.3.7 - eventemitter2: 6.4.9 - extrareqp2: 1.0.0(debug@4.3.7) - ws: 7.5.10 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - '@pm2/pm2-version-check@1.0.4': - dependencies: - debug: 4.4.1(supports-color@5.5.0) - transitivePeerDependencies: - - supports-color - '@pnpm/config.env-replace@1.1.0': {} '@pnpm/network.ca-file@1.0.2': @@ -16500,7 +16203,7 @@ snapshots: storybook: 9.1.3(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)) type-fest: 2.19.0 vue: 3.5.20(typescript@5.9.2) - vue-component-type-helpers: 3.1.3 + vue-component-type-helpers: 3.1.4 '@swc/core-darwin-arm64@1.13.5': optional: true @@ -16728,8 +16431,6 @@ snapshots: '@tokenizer/token@0.3.0': {} - '@tootallnate/quickjs-emscripten@0.23.0': {} - '@ts-morph/common@0.25.0': dependencies: minimatch: 9.0.5 @@ -17885,14 +17586,6 @@ snapshots: alien-signals@2.0.5: {} - amp-message@0.1.2: - dependencies: - amp: 0.3.1 - - amp@0.3.1: {} - - ansi-colors@4.1.3: {} - ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -17923,8 +17616,6 @@ snapshots: ansi_up@6.0.6: {} - ansis@4.0.0-node10: {} - ansis@4.1.0: {} anymatch@3.1.3: @@ -18065,10 +17756,6 @@ snapshots: dependencies: tslib: 2.8.1 - ast-types@0.13.4: - dependencies: - tslib: 2.8.1 - ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -18096,10 +17783,6 @@ snapshots: async@1.5.2: {} - async@2.6.4: - dependencies: - lodash: 4.17.21 - async@3.2.6: {} asynckit@0.4.0: {} @@ -18138,7 +17821,7 @@ snapshots: axios@0.26.1: dependencies: - follow-redirects: 1.15.9(debug@4.3.7) + follow-redirects: 1.15.9 transitivePeerDependencies: - debug @@ -18203,8 +17886,6 @@ snapshots: dependencies: safe-buffer: 5.1.2 - basic-ftp@5.0.5: {} - bcrypt-pbkdf@1.0.2: dependencies: tweetnacl: 0.14.5 @@ -18229,12 +17910,8 @@ snapshots: blake3-wasm@2.1.5: {} - blessed@0.1.81: {} - blob-to-buffer@1.2.9: {} - bodec@0.1.0: {} - body-parser@1.20.3: dependencies: bytes: 3.1.2 @@ -18489,8 +18166,6 @@ snapshots: chardet@2.1.0: {} - charm@0.1.2: {} - check-error@2.1.1: {} chokidar@3.6.0: @@ -18556,10 +18231,6 @@ snapshots: dependencies: colors: 1.0.3 - cli-tableau@2.0.1: - dependencies: - chalk: 3.0.0 - cli-truncate@4.0.0: dependencies: slice-ansi: 5.0.0 @@ -18649,8 +18320,6 @@ snapshots: commander@14.0.0: {} - commander@2.15.1: {} - commander@2.20.3: {} commander@5.1.0: {} @@ -18844,8 +18513,6 @@ snapshots: '@types/luxon': 3.6.2 luxon: 3.6.1 - croner@4.1.97: {} - croner@9.1.0: {} cross-fetch@3.2.0: @@ -18969,8 +18636,6 @@ snapshots: csv-parse@5.6.0: {} - culvert@0.1.2: {} - d@1.0.2: dependencies: es5-ext: 0.10.64 @@ -18978,8 +18643,6 @@ snapshots: data-uri-to-buffer@4.0.1: {} - data-uri-to-buffer@6.0.2: {} - data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -19009,12 +18672,8 @@ snapshots: dateformat@4.6.3: {} - dayjs@1.11.13: {} - dayjs@1.11.14: {} - dayjs@1.8.36: {} - db0@0.3.2: {} de-indent@1.0.2: {} @@ -19029,10 +18688,6 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.3.7: - dependencies: - ms: 2.1.3 - debug@4.4.1(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -19116,12 +18771,6 @@ snapshots: defu@6.1.4: {} - degenerator@5.0.1: - dependencies: - ast-types: 0.13.4 - escodegen: 2.1.0 - esprima: 4.0.1 - delayed-stream@1.0.0: {} denque@2.1.0: {} @@ -19327,10 +18976,6 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.2.2 - enquirer@2.3.6: - dependencies: - ansi-colors: 4.1.3 - entities@4.5.0: {} entities@6.0.1: {} @@ -19671,14 +19316,6 @@ snapshots: escape-string-regexp@5.0.0: {} - escodegen@2.1.0: - dependencies: - esprima: 4.0.1 - estraverse: 5.3.0 - esutils: 2.0.3 - optionalDependencies: - source-map: 0.6.1 - eslint-config-prettier@10.1.8(eslint@9.34.0(jiti@2.5.1)): dependencies: eslint: 9.34.0(jiti@2.5.1) @@ -19910,8 +19547,6 @@ snapshots: event-target-shim@5.0.1: {} - eventemitter2@5.0.1: {} - eventemitter2@6.4.9: {} eventemitter3@3.1.2: {} @@ -20029,12 +19664,6 @@ snapshots: extract-files@11.0.0: {} - extrareqp2@1.0.0(debug@4.3.7): - dependencies: - follow-redirects: 1.15.9(debug@4.3.7) - transitivePeerDependencies: - - debug - fast-check@4.2.0: dependencies: pure-rand: 7.0.1 @@ -20057,8 +19686,6 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fast-json-patch@3.1.1: {} - fast-json-stable-stringify@2.1.0: {} fast-json-stringify@6.0.1: @@ -20150,8 +19777,6 @@ snapshots: transitivePeerDependencies: - encoding - fclone@1.0.11: {} - fd-package-json@2.0.0: dependencies: walk-up-path: 4.0.0 @@ -20254,9 +19879,7 @@ snapshots: dependencies: tabbable: 6.2.0 - follow-redirects@1.15.9(debug@4.3.7): - optionalDependencies: - debug: 4.3.7 + follow-redirects@1.15.9: {} fontaine@0.6.0: dependencies: @@ -20416,14 +20039,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - get-uri@6.0.4: - dependencies: - basic-ftp: 5.0.5 - data-uri-to-buffer: 6.0.2 - debug: 4.4.1(supports-color@5.5.0) - transitivePeerDependencies: - - supports-color - giget@2.0.0: dependencies: citty: 0.1.6 @@ -20433,12 +20048,6 @@ snapshots: nypm: 0.6.1 pathe: 2.0.3 - git-node-fs@1.0.0(js-git@0.7.8): - optionalDependencies: - js-git: 0.7.8 - - git-sha1@0.1.2: {} - git-up@8.1.1: dependencies: is-ssh: 1.4.1 @@ -20752,7 +20361,7 @@ snapshots: http-proxy@1.18.1: dependencies: eventemitter3: 4.0.7 - follow-redirects: 1.15.9(debug@4.3.7) + follow-redirects: 1.15.9 requires-port: 1.0.0 transitivePeerDependencies: - debug @@ -20932,11 +20541,6 @@ snapshots: transitivePeerDependencies: - supports-color - ip-address@9.0.5: - dependencies: - jsbn: 1.1.0 - sprintf-js: 1.1.3 - ip@2.0.1: {} ipaddr.js@1.9.1: {} @@ -21255,13 +20859,6 @@ snapshots: js-cookie@3.0.5: {} - js-git@0.7.8: - dependencies: - bodec: 0.1.0 - culvert: 0.1.2 - git-sha1: 0.1.2 - pako: 0.2.9 - js-stringify@1.0.2: {} js-tokens@4.0.0: {} @@ -21272,8 +20869,6 @@ snapshots: dependencies: argparse: 2.0.1 - jsbn@1.1.0: {} - jsdom@26.1.0: dependencies: cssstyle: 4.5.0 @@ -21815,8 +21410,6 @@ snapshots: mkdirp-classic@0.5.3: {} - mkdirp@1.0.4: {} - mkdirp@3.0.1: {} mlly@1.7.4: @@ -21835,8 +21428,6 @@ snapshots: mocked-exports@0.1.1: {} - module-details-from-path@1.0.3: {} - motion-dom@12.23.12: dependencies: motion-utils: 12.23.6 @@ -21896,14 +21487,6 @@ snapshots: natural-compare@1.4.0: {} - needle@2.4.0: - dependencies: - debug: 3.2.7 - iconv-lite: 0.4.24 - sax: 1.4.1 - transitivePeerDependencies: - - supports-color - negotiator@0.6.3: {} negotiator@0.6.4: {} @@ -21949,8 +21532,6 @@ snapshots: qs: 6.14.0 optional: true - netmask@2.0.2: {} - next-tick@1.1.0: {} nitropack@2.12.5(@netlify/blobs@9.1.2)(xml2js@0.6.2): @@ -22580,24 +22161,6 @@ snapshots: p-timeout: 6.1.4 optional: true - pac-proxy-agent@7.1.0: - dependencies: - '@tootallnate/quickjs-emscripten': 0.23.0 - agent-base: 7.1.3 - debug: 4.4.1(supports-color@5.5.0) - get-uri: 6.0.4 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - pac-resolver: 7.0.1 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - pac-resolver@7.0.1: - dependencies: - degenerator: 5.0.1 - netmask: 2.0.2 - package-json-from-dist@1.0.1: {} package-json@10.0.1: @@ -22746,15 +22309,6 @@ snapshots: pidtree@0.6.0: {} - pidusage@2.0.21: - dependencies: - safe-buffer: 5.2.1 - optional: true - - pidusage@3.0.2: - dependencies: - safe-buffer: 5.2.1 - pify@2.3.0: {} pify@3.0.0: {} @@ -22840,79 +22394,6 @@ snapshots: dependencies: find-up: 3.0.0 - pm2-axon-rpc@0.7.1: - dependencies: - debug: 4.4.1(supports-color@5.5.0) - transitivePeerDependencies: - - supports-color - - pm2-axon@4.0.1: - dependencies: - amp: 0.3.1 - amp-message: 0.1.2 - debug: 4.4.1(supports-color@5.5.0) - escape-string-regexp: 4.0.0 - transitivePeerDependencies: - - supports-color - - pm2-deploy@1.0.2: - dependencies: - run-series: 1.1.9 - tv4: 1.3.0 - - pm2-multimeter@0.1.2: - dependencies: - charm: 0.1.2 - - pm2-sysmonit@1.2.8: - dependencies: - async: 3.2.6 - debug: 4.4.1(supports-color@5.5.0) - pidusage: 2.0.21 - systeminformation: 5.27.8 - tx2: 1.0.5 - transitivePeerDependencies: - - supports-color - optional: true - - pm2@6.0.8: - dependencies: - '@pm2/agent': 2.1.1 - '@pm2/io': 6.1.0 - '@pm2/js-api': 0.8.0 - '@pm2/pm2-version-check': 1.0.4 - ansis: 4.0.0-node10 - async: 3.2.6 - blessed: 0.1.81 - chokidar: 3.6.0 - cli-tableau: 2.0.1 - commander: 2.15.1 - croner: 4.1.97 - dayjs: 1.11.13 - debug: 4.4.1(supports-color@5.5.0) - enquirer: 2.3.6 - eventemitter2: 5.0.1 - fclone: 1.0.11 - js-yaml: 4.1.0 - mkdirp: 1.0.4 - needle: 2.4.0 - pidusage: 3.0.2 - pm2-axon: 4.0.1 - pm2-axon-rpc: 0.7.1 - pm2-deploy: 1.0.2 - pm2-multimeter: 0.1.2 - promptly: 2.2.0 - semver: 7.7.2 - source-map-support: 0.5.21 - sprintf-js: 1.1.2 - vizion: 2.2.1 - optionalDependencies: - pm2-sysmonit: 1.2.8 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - portfinder@1.0.35: dependencies: async: 3.2.6 @@ -23162,10 +22643,6 @@ snapshots: dependencies: asap: 2.0.6 - promptly@2.2.0: - dependencies: - read: 1.0.7 - prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -23207,21 +22684,6 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - proxy-agent@6.4.0: - dependencies: - agent-base: 7.1.3 - debug: 4.4.1(supports-color@5.5.0) - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - lru-cache: 7.18.3 - pac-proxy-agent: 7.1.0 - proxy-from-env: 1.1.0 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - proxy-from-env@1.1.0: {} - pstree.remy@1.1.8: {} pug-attrs@3.0.0: @@ -23362,10 +22824,6 @@ snapshots: dependencies: pify: 2.3.0 - read@1.0.7: - dependencies: - mute-stream: 0.0.8 - readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -23525,14 +22983,6 @@ snapshots: require-from-string@2.0.2: {} - require-in-the-middle@5.2.0: - dependencies: - debug: 4.4.1(supports-color@5.5.0) - module-details-from-path: 1.0.3 - resolve: 1.22.10 - transitivePeerDependencies: - - supports-color - require-main-filename@2.0.0: {} requires-port@1.0.0: {} @@ -23680,8 +23130,6 @@ snapshots: dependencies: queue-microtask: 1.2.3 - run-series@1.1.9: {} - rxjs@6.6.7: dependencies: tslib: 1.14.1 @@ -23924,8 +23372,6 @@ snapshots: shell-quote@1.8.3: {} - shimmer@1.2.1: {} - side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -24008,8 +23454,6 @@ snapshots: ansi-styles: 6.2.1 is-fullwidth-code-point: 5.0.0 - smart-buffer@4.2.0: {} - smob@1.5.0: {} snake-case@3.0.4: @@ -24017,19 +23461,6 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 - socks-proxy-agent@8.0.5: - dependencies: - agent-base: 7.1.3 - debug: 4.4.1(supports-color@5.5.0) - socks: 2.8.4 - transitivePeerDependencies: - - supports-color - - socks@2.8.4: - dependencies: - ip-address: 9.0.5 - smart-buffer: 4.2.0 - sonic-boom@4.2.0: dependencies: atomic-sleep: 1.0.0 @@ -24078,8 +23509,6 @@ snapshots: sprintf-js@1.0.3: {} - sprintf-js@1.1.2: {} - sprintf-js@1.1.3: {} ssh2@1.16.0: @@ -24570,8 +23999,6 @@ snapshots: tslib@1.14.1: {} - tslib@1.9.3: {} - tslib@2.4.1: {} tslib@2.6.3: {} @@ -24585,17 +24012,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - tv4@1.3.0: {} - tw-animate-css@1.3.7: {} tweetnacl@0.14.5: {} - tx2@1.0.5: - dependencies: - json-stringify-safe: 5.0.1 - optional: true - type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -25311,13 +24731,6 @@ snapshots: - tsx - yaml - vizion@2.2.1: - dependencies: - async: 2.6.4 - git-node-fs: 1.0.0(js-git@0.7.8) - ini: 1.3.8 - js-git: 0.7.8 - void-elements@3.1.0: {} vscode-uri@3.1.0: {} @@ -25339,7 +24752,7 @@ snapshots: vue-component-type-helpers@3.0.6: {} - vue-component-type-helpers@3.1.3: {} + vue-component-type-helpers@3.1.4: {} vue-demi@0.14.10(vue@3.5.20(typescript@5.9.2)): dependencies: diff --git a/web/composables/gql/graphql.ts b/web/composables/gql/graphql.ts index a6171b6772..8335211509 100644 --- a/web/composables/gql/graphql.ts +++ b/web/composables/gql/graphql.ts @@ -1523,8 +1523,8 @@ export type PackageVersions = { openssl?: Maybe; /** PHP version */ php?: Maybe; - /** pm2 version */ - pm2?: Maybe; + /** nodemon version */ + nodemon?: Maybe; }; export type ParityCheck = { diff --git a/web/src/composables/gql/graphql.ts b/web/src/composables/gql/graphql.ts index e683aa0c02..a44fe13e7c 100644 --- a/web/src/composables/gql/graphql.ts +++ b/web/src/composables/gql/graphql.ts @@ -559,6 +559,17 @@ export type CpuLoad = { percentUser: Scalars['Float']['output']; }; +export type CpuPackages = Node & { + __typename?: 'CpuPackages'; + id: Scalars['PrefixedID']['output']; + /** Power draw per package (W) */ + power: Array; + /** Temperature per package (°C) */ + temp: Array; + /** Total CPU package power draw (W) */ + totalPower: Scalars['Float']['output']; +}; + export type CpuUtilization = Node & { __typename?: 'CpuUtilization'; /** CPU load for each core */ @@ -869,6 +880,7 @@ export type InfoCpu = Node & { manufacturer?: Maybe; /** CPU model */ model?: Maybe; + packages: CpuPackages; /** Number of physical processors */ processors?: Maybe; /** CPU revision */ @@ -885,6 +897,8 @@ export type InfoCpu = Node & { stepping?: Maybe; /** Number of CPU threads */ threads?: Maybe; + /** Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]] */ + topology: Array>>; /** CPU vendor */ vendor?: Maybe; /** CPU voltage */ @@ -1531,14 +1545,14 @@ export type PackageVersions = { nginx?: Maybe; /** Node.js version */ node?: Maybe; + /** nodemon version */ + nodemon?: Maybe; /** npm version */ npm?: Maybe; /** OpenSSL version */ openssl?: Maybe; /** PHP version */ php?: Maybe; - /** pm2 version */ - pm2?: Maybe; }; export type ParityCheck = { @@ -2053,6 +2067,7 @@ export type Subscription = { parityHistorySubscription: ParityCheck; serversSubscription: Server; systemMetricsCpu: CpuUtilization; + systemMetricsCpuTelemetry: CpuPackages; systemMetricsMemory: MemoryUtilization; upsUpdates: UpsDevice; }; diff --git a/web/src/composables/gql/index.ts b/web/src/composables/gql/index.ts index c682b1e2f9..0ea4a91cf8 100644 --- a/web/src/composables/gql/index.ts +++ b/web/src/composables/gql/index.ts @@ -1,2 +1,2 @@ -export * from './fragment-masking'; -export * from './gql'; +export * from "./fragment-masking"; +export * from "./gql"; From f6521d8c1ca7e91ea3f5bf5dba64c529b6b633d1 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 19 Nov 2025 20:59:07 -0500 Subject: [PATCH 02/16] Add SUPPRESS_LOGS to environment mocks --- api/src/__test__/graphql/resolvers/rclone-api.service.test.ts | 2 ++ api/src/unraid-api/cli/nodemon.service.spec.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts b/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts index e4adb7452b..1ac4560378 100644 --- a/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts +++ b/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts @@ -51,6 +51,8 @@ vi.mock('@app/store/index.js', () => ({ })); vi.mock('@app/environment.js', () => ({ ENVIRONMENT: 'development', + SUPPRESS_LOGS: false, + LOG_LEVEL: 'INFO', environment: { IS_MAIN_PROCESS: true, }, diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index 7ee7dae84b..4d09f15c16 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -16,6 +16,8 @@ vi.mock('@app/core/utils/files/file-exists.js', () => ({ fileExists: vi.fn().mockResolvedValue(false), })); vi.mock('@app/environment.js', () => ({ + LOG_LEVEL: 'INFO', + SUPPRESS_LOGS: false, NODEMON_CONFIG_PATH: '/etc/unraid-api/nodemon.json', NODEMON_PATH: '/usr/bin/nodemon', NODEMON_PID_PATH: '/var/run/unraid-api/nodemon.pid', From 6d3d623b6672980a79568b6cafe0f74a4b64f8cf Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 21 Nov 2025 16:00:31 -0500 Subject: [PATCH 03/16] feat: Refactor nodemon configuration and improve error handling in NodemonService - Updated nodemon.json to remove unnecessary watch entry. - Adjusted NODEMON_CONFIG_PATH and UNRAID_API_CWD paths for better structure. - Enhanced error handling in isUnraidApiRunning and start methods of NodemonService to ensure proper logging and resource management. - Added tests for error scenarios in NodemonService to ensure robustness. --- api/nodemon.json | 3 +- .../core/utils/process/unraid-api-running.ts | 18 +-- api/src/environment.ts | 4 +- .../unraid-api/cli/nodemon.service.spec.ts | 109 +++++++++++++++++- api/src/unraid-api/cli/nodemon.service.ts | 80 ++++++++----- 5 files changed, 174 insertions(+), 40 deletions(-) diff --git a/api/nodemon.json b/api/nodemon.json index 91e2dfae2a..a97e2430a9 100644 --- a/api/nodemon.json +++ b/api/nodemon.json @@ -1,7 +1,6 @@ { "watch": [ - "dist/main.js", - "myservers.cfg" + "dist/main.js" ], "ignore": [ "node_modules", diff --git a/api/src/core/utils/process/unraid-api-running.ts b/api/src/core/utils/process/unraid-api-running.ts index d1361b21fa..c4ee4d7e67 100644 --- a/api/src/core/utils/process/unraid-api-running.ts +++ b/api/src/core/utils/process/unraid-api-running.ts @@ -4,17 +4,17 @@ import { fileExists } from '@app/core/utils/files/file-exists.js'; import { NODEMON_PID_PATH } from '@app/environment.js'; export const isUnraidApiRunning = async (): Promise => { - if (!(await fileExists(NODEMON_PID_PATH))) { - return false; - } + try { + if (!(await fileExists(NODEMON_PID_PATH))) { + return false; + } - const pidText = (await readFile(NODEMON_PID_PATH, 'utf-8')).trim(); - const pid = Number.parseInt(pidText, 10); - if (Number.isNaN(pid)) { - return false; - } + const pidText = (await readFile(NODEMON_PID_PATH, 'utf-8')).trim(); + const pid = Number.parseInt(pidText, 10); + if (Number.isNaN(pid)) { + return false; + } - try { process.kill(pid, 0); return true; } catch { diff --git a/api/src/environment.ts b/api/src/environment.ts index 3ab43336bf..6f085e41c2 100644 --- a/api/src/environment.ts +++ b/api/src/environment.ts @@ -110,9 +110,9 @@ export const NODEMON_PATH = join( 'bin', 'nodemon.js' ); -export const NODEMON_CONFIG_PATH = join(import.meta.dirname, '../../', 'nodemon.json'); +export const NODEMON_CONFIG_PATH = join(import.meta.dirname, '../', 'nodemon.json'); export const NODEMON_PID_PATH = process.env.NODEMON_PID_PATH ?? '/var/run/unraid-api/nodemon.pid'; -export const UNRAID_API_CWD = process.env.UNRAID_API_CWD ?? join(import.meta.dirname, '../../'); +export const UNRAID_API_CWD = process.env.UNRAID_API_CWD ?? join(import.meta.dirname, '../'); export const PATHS_CONFIG_MODULES = process.env.PATHS_CONFIG_MODULES ?? '/boot/config/plugins/dynamix.my.servers/configs'; diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index 4d09f15c16..1799e3c221 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -8,7 +8,7 @@ import { fileExists } from '@app/core/utils/files/file-exists.js'; import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; vi.mock('node:fs', () => ({ - createWriteStream: vi.fn(() => ({ pipe: vi.fn() })), + createWriteStream: vi.fn(() => ({ pipe: vi.fn(), close: vi.fn() })), })); vi.mock('node:fs/promises'); vi.mock('execa', () => ({ execa: vi.fn() })); @@ -57,8 +57,21 @@ describe('NodemonService', () => { expect(mockMkdir).toHaveBeenCalledWith('/var/run/unraid-api', { recursive: true }); }); + it('throws error when directory creation fails', async () => { + const service = new NodemonService(logger); + const error = new Error('Permission denied'); + mockMkdir.mockRejectedValue(error); + + await expect(service.ensureNodemonDependencies()).rejects.toThrow('Permission denied'); + expect(mockMkdir).toHaveBeenCalledWith('/var/log/unraid-api', { recursive: true }); + }); + it('starts nodemon and writes pid file', async () => { const service = new NodemonService(logger); + const logStream = { pipe: vi.fn(), close: vi.fn() }; + vi.mocked(createWriteStream).mockReturnValue( + logStream as unknown as ReturnType + ); const stdout = { pipe: vi.fn() }; const stderr = { pipe: vi.fn() }; const unref = vi.fn(); @@ -87,6 +100,60 @@ describe('NodemonService', () => { expect(unref).toHaveBeenCalled(); expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '123'); expect(logger.info).toHaveBeenCalledWith('Started nodemon (pid 123)'); + expect(logStream.close).not.toHaveBeenCalled(); + }); + + it('throws error and aborts start when directory creation fails', async () => { + const service = new NodemonService(logger); + const error = new Error('Permission denied'); + mockMkdir.mockRejectedValue(error); + + await expect(service.start()).rejects.toThrow('Permission denied'); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to ensure nodemon dependencies: Permission denied' + ); + expect(execa).not.toHaveBeenCalled(); + }); + + it('throws error and closes logStream when execa fails', async () => { + const service = new NodemonService(logger); + const logStream = { pipe: vi.fn(), close: vi.fn() }; + vi.mocked(createWriteStream).mockReturnValue( + logStream as unknown as ReturnType + ); + const error = new Error('Command not found'); + vi.mocked(execa).mockImplementation(() => { + throw error; + }); + + await expect(service.start()).rejects.toThrow('Failed to start nodemon: Command not found'); + expect(logStream.close).toHaveBeenCalled(); + expect(mockWriteFile).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + }); + + it('throws error and closes logStream when pid is missing', async () => { + const service = new NodemonService(logger); + const logStream = { pipe: vi.fn(), close: vi.fn() }; + vi.mocked(createWriteStream).mockReturnValue( + logStream as unknown as ReturnType + ); + const stdout = { pipe: vi.fn() }; + const stderr = { pipe: vi.fn() }; + const unref = vi.fn(); + vi.mocked(execa).mockReturnValue({ + pid: undefined, + stdout, + stderr, + unref, + } as unknown as ReturnType); + + await expect(service.start()).rejects.toThrow( + 'Failed to start nodemon: process spawned but no PID was assigned' + ); + expect(logStream.close).toHaveBeenCalled(); + expect(mockWriteFile).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); }); it('returns not running when pid file is missing', async () => { @@ -98,4 +165,44 @@ describe('NodemonService', () => { expect(result).toBe(false); expect(logger.info).toHaveBeenCalledWith('unraid-api is not running (no pid file).'); }); + + it('logs stdout when tail succeeds', async () => { + const service = new NodemonService(logger); + vi.mocked(execa).mockResolvedValue({ + stdout: 'log line 1\nlog line 2', + } as unknown as Awaited>); + + const result = await service.logs(50); + + expect(execa).toHaveBeenCalledWith('tail', ['-n', '50', '/var/log/graphql-api.log']); + expect(logger.log).toHaveBeenCalledWith('log line 1\nlog line 2'); + expect(result).toBe('log line 1\nlog line 2'); + }); + + it('handles ENOENT error when log file is missing', async () => { + const service = new NodemonService(logger); + const error = new Error('ENOENT: no such file or directory'); + (error as Error & { code?: string }).code = 'ENOENT'; + vi.mocked(execa).mockRejectedValue(error); + + const result = await service.logs(); + + expect(logger.error).toHaveBeenCalledWith( + 'Log file not found: /var/log/graphql-api.log (ENOENT: no such file or directory)' + ); + expect(result).toBe(''); + }); + + it('handles non-zero exit error from tail', async () => { + const service = new NodemonService(logger); + const error = new Error('Command failed with exit code 1'); + vi.mocked(execa).mockRejectedValue(error); + + const result = await service.logs(100); + + expect(logger.error).toHaveBeenCalledWith( + 'Failed to read logs from /var/log/graphql-api.log: Command failed with exit code 1' + ); + expect(result).toBe(''); + }); }); diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index 1671e45263..8e558b27ed 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -32,14 +32,8 @@ export class NodemonService { constructor(private readonly logger: LogService) {} async ensureNodemonDependencies() { - try { - await mkdir(PATHS_LOGS_DIR, { recursive: true }); - await mkdir(dirname(NODEMON_PID_PATH), { recursive: true }); - } catch (error) { - this.logger.error( - `Failed to fully ensure nodemon dependencies: ${error instanceof Error ? error.message : error}` - ); - } + await mkdir(PATHS_LOGS_DIR, { recursive: true }); + await mkdir(dirname(NODEMON_PID_PATH), { recursive: true }); } private async getStoredPid(): Promise { @@ -59,28 +53,47 @@ export class NodemonService { } async start(options: StartOptions = {}) { - await this.ensureNodemonDependencies(); + try { + await this.ensureNodemonDependencies(); + } catch (error) { + this.logger.error( + `Failed to ensure nodemon dependencies: ${error instanceof Error ? error.message : error}` + ); + throw error; + } + await this.stop({ quiet: true }); - const env = { ...process.env, ...options.env } as Record; + const overrides = Object.fromEntries( + Object.entries(options.env ?? {}).filter(([, value]) => value !== undefined) + ); + const env = { ...process.env, ...overrides } as Record; const logStream = createWriteStream(PATHS_LOGS_FILE, { flags: 'a' }); - const nodemonProcess = execa(NODEMON_PATH, ['--config', NODEMON_CONFIG_PATH, '--quiet'], { - cwd: UNRAID_API_CWD, - env, - detached: true, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - nodemonProcess.stdout?.pipe(logStream); - nodemonProcess.stderr?.pipe(logStream); - nodemonProcess.unref(); + let nodemonProcess; + try { + nodemonProcess = execa(NODEMON_PATH, ['--config', NODEMON_CONFIG_PATH, '--quiet'], { + cwd: UNRAID_API_CWD, + env, + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + nodemonProcess.stdout?.pipe(logStream); + nodemonProcess.stderr?.pipe(logStream); + nodemonProcess.unref(); + + if (!nodemonProcess.pid) { + logStream.close(); + throw new Error('Failed to start nodemon: process spawned but no PID was assigned'); + } - if (nodemonProcess.pid) { await writeFile(NODEMON_PID_PATH, `${nodemonProcess.pid}`); this.logger.info(`Started nodemon (pid ${nodemonProcess.pid})`); - } else { - this.logger.error('Failed to determine nodemon pid.'); + } catch (error) { + logStream.close(); + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to start nodemon: ${errorMessage}`); } } @@ -126,8 +139,23 @@ export class NodemonService { return running; } - async logs(lines = 100) { - const { stdout } = await execa('tail', ['-n', `${lines}`, PATHS_LOGS_FILE]); - this.logger.log(stdout); + async logs(lines = 100): Promise { + try { + const { stdout } = await execa('tail', ['-n', `${lines}`, PATHS_LOGS_FILE]); + this.logger.log(stdout); + return stdout; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const isFileNotFound = + errorMessage.includes('ENOENT') || + (error instanceof Error && 'code' in error && error.code === 'ENOENT'); + + if (isFileNotFound) { + this.logger.error(`Log file not found: ${PATHS_LOGS_FILE} (${errorMessage})`); + } else { + this.logger.error(`Failed to read logs from ${PATHS_LOGS_FILE}: ${errorMessage}`); + } + return ''; + } } } From b35da132346d96b91b5bbcd79067554269f914c7 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 21 Nov 2025 16:53:01 -0500 Subject: [PATCH 04/16] refactor: Update environment configuration for nodemon paths - Introduced UNRAID_API_ROOT to streamline path definitions for nodemon. - Replaced direct usage of import.meta.dirname with UNRAID_API_ROOT in NODEMON_PATH and UNRAID_API_CWD for improved clarity and maintainability. - Added dirname import to facilitate the new path structure. --- api/src/environment.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/api/src/environment.ts b/api/src/environment.ts index 6f085e41c2..cef6282071 100644 --- a/api/src/environment.ts +++ b/api/src/environment.ts @@ -2,7 +2,7 @@ // Non-function exports from this module are loaded into the NestJS Config at runtime. import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { PackageJson, SetRequired } from 'type-fest'; @@ -65,6 +65,7 @@ export const getPackageJsonDependencies = (): string[] | undefined => { }; export const API_VERSION = process.env.npm_package_version ?? getPackageJson().version; +export const UNRAID_API_ROOT = dirname(getPackageJsonPath()); /** Controls how the app is built/run (i.e. in terms of optimization) */ export const NODE_ENV = @@ -102,17 +103,10 @@ export const PATHS_LOGS_DIR = process.env.PATHS_LOGS_DIR ?? process.env.LOGS_DIR ?? '/var/log/unraid-api'; export const PATHS_LOGS_FILE = process.env.PATHS_LOGS_FILE ?? '/var/log/graphql-api.log'; -export const NODEMON_PATH = join( - import.meta.dirname, - '../../', - 'node_modules', - 'nodemon', - 'bin', - 'nodemon.js' -); -export const NODEMON_CONFIG_PATH = join(import.meta.dirname, '../', 'nodemon.json'); +export const NODEMON_PATH = join(UNRAID_API_ROOT, 'node_modules', 'nodemon', 'bin', 'nodemon.js'); +export const NODEMON_CONFIG_PATH = join(UNRAID_API_ROOT, 'nodemon.json'); export const NODEMON_PID_PATH = process.env.NODEMON_PID_PATH ?? '/var/run/unraid-api/nodemon.pid'; -export const UNRAID_API_CWD = process.env.UNRAID_API_CWD ?? join(import.meta.dirname, '../'); +export const UNRAID_API_CWD = process.env.UNRAID_API_CWD ?? UNRAID_API_ROOT; export const PATHS_CONFIG_MODULES = process.env.PATHS_CONFIG_MODULES ?? '/boot/config/plugins/dynamix.my.servers/configs'; From d4f90d6d64321f8412a93e4dedfe45d2abdbb836 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 21 Nov 2025 17:23:16 -0500 Subject: [PATCH 05/16] test: Add unit tests for nodemon path configuration and enhance error handling - Introduced a new test file to validate nodemon path configurations, ensuring they anchor to the package root by default. - Enhanced the NodemonService to throw an error when nodemon exits immediately after starting, improving robustness. - Added tests to cover scenarios where nodemon fails to start, ensuring proper logging and resource cleanup. --- .../environment.nodemon-paths.test.ts | 29 +++++++++++++++++++ .../unraid-api/cli/nodemon.service.spec.ts | 29 +++++++++++++++++++ api/src/unraid-api/cli/nodemon.service.ts | 12 ++++++++ 3 files changed, 70 insertions(+) create mode 100644 api/src/__test__/environment.nodemon-paths.test.ts diff --git a/api/src/__test__/environment.nodemon-paths.test.ts b/api/src/__test__/environment.nodemon-paths.test.ts new file mode 100644 index 0000000000..3e5ac9a468 --- /dev/null +++ b/api/src/__test__/environment.nodemon-paths.test.ts @@ -0,0 +1,29 @@ +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('nodemon path configuration', () => { + const originalUnraidApiCwd = process.env.UNRAID_API_CWD; + + beforeEach(() => { + vi.resetModules(); + delete process.env.UNRAID_API_CWD; + }); + + afterEach(() => { + if (originalUnraidApiCwd === undefined) { + delete process.env.UNRAID_API_CWD; + } else { + process.env.UNRAID_API_CWD = originalUnraidApiCwd; + } + }); + + it('anchors nodemon paths to the package root by default', async () => { + const environment = await import('@app/environment.js'); + const { UNRAID_API_ROOT, NODEMON_CONFIG_PATH, NODEMON_PATH, UNRAID_API_CWD } = environment; + + expect(UNRAID_API_CWD).toBe(UNRAID_API_ROOT); + expect(NODEMON_CONFIG_PATH).toBe(join(UNRAID_API_ROOT, 'nodemon.json')); + expect(NODEMON_PATH).toBe(join(UNRAID_API_ROOT, 'node_modules', 'nodemon', 'bin', 'nodemon.js')); + }); +}); diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index 1799e3c221..f6eb6d631c 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -39,6 +39,7 @@ describe('NodemonService', () => { const mockMkdir = vi.mocked(fs.mkdir); const mockWriteFile = vi.mocked(fs.writeFile); const mockRm = vi.mocked(fs.rm); + const killSpy = vi.spyOn(process, 'kill'); beforeEach(() => { vi.clearAllMocks(); @@ -46,6 +47,7 @@ describe('NodemonService', () => { mockWriteFile.mockResolvedValue(undefined as unknown as void); mockRm.mockResolvedValue(undefined as unknown as void); vi.mocked(fileExists).mockResolvedValue(false); + killSpy.mockReturnValue(true as unknown as boolean); }); it('ensures directories needed by nodemon exist', async () => { @@ -81,6 +83,7 @@ describe('NodemonService', () => { stderr, unref, } as unknown as ReturnType); + killSpy.mockReturnValue(true as unknown as boolean); await service.start({ env: { LOG_LEVEL: 'DEBUG' } }); @@ -156,6 +159,32 @@ describe('NodemonService', () => { expect(logger.info).not.toHaveBeenCalled(); }); + it('throws when nodemon exits immediately after start', async () => { + const service = new NodemonService(logger); + const logStream = { pipe: vi.fn(), close: vi.fn() }; + vi.mocked(createWriteStream).mockReturnValue( + logStream as unknown as ReturnType + ); + const stdout = { pipe: vi.fn() }; + const stderr = { pipe: vi.fn() }; + const unref = vi.fn(); + vi.mocked(execa).mockReturnValue({ + pid: 456, + stdout, + stderr, + unref, + } as unknown as ReturnType); + killSpy.mockImplementation(() => { + throw new Error('not running'); + }); + const logsSpy = vi.spyOn(service, 'logs').mockResolvedValue('recent log lines'); + + await expect(service.start()).rejects.toThrow(/Nodemon exited immediately/); + expect(logStream.close).toHaveBeenCalled(); + expect(mockRm).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', { force: true }); + expect(logsSpy).toHaveBeenCalledWith(50); + }); + it('returns not running when pid file is missing', async () => { const service = new NodemonService(logger); vi.mocked(fileExists).mockResolvedValue(false); diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index 8e558b27ed..e1faf3f30e 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -89,6 +89,18 @@ export class NodemonService { } await writeFile(NODEMON_PID_PATH, `${nodemonProcess.pid}`); + + // Give nodemon a brief moment to boot, then verify it is still alive. + await new Promise((resolve) => setTimeout(resolve, 200)); + const stillRunning = await this.isPidRunning(nodemonProcess.pid); + if (!stillRunning) { + const recentLogs = await this.logs(50); + await rm(NODEMON_PID_PATH, { force: true }); + logStream.close(); + const logMessage = recentLogs ? ` Recent logs:\n${recentLogs}` : ''; + throw new Error(`Nodemon exited immediately after start.${logMessage}`); + } + this.logger.info(`Started nodemon (pid ${nodemonProcess.pid})`); } catch (error) { logStream.close(); From 33e88bc5f566bec89f16bd40b2c6f8c17ce94cad Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 21 Nov 2025 18:06:40 -0500 Subject: [PATCH 06/16] fix: Simplify return type for killSpy in NodemonService tests - Updated the return type of killSpy in nodemon.service.spec.ts to directly return a boolean instead of casting it, improving code clarity and maintainability. --- api/src/unraid-api/cli/nodemon.service.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index f6eb6d631c..6bfd7c3ede 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -47,7 +47,7 @@ describe('NodemonService', () => { mockWriteFile.mockResolvedValue(undefined as unknown as void); mockRm.mockResolvedValue(undefined as unknown as void); vi.mocked(fileExists).mockResolvedValue(false); - killSpy.mockReturnValue(true as unknown as boolean); + killSpy.mockReturnValue(true); }); it('ensures directories needed by nodemon exist', async () => { @@ -83,7 +83,7 @@ describe('NodemonService', () => { stderr, unref, } as unknown as ReturnType); - killSpy.mockReturnValue(true as unknown as boolean); + killSpy.mockReturnValue(true); await service.start({ env: { LOG_LEVEL: 'DEBUG' } }); From 1d9c76f4108be9e372f1c36bf66894ff3afe788c Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 21 Nov 2025 18:53:21 -0500 Subject: [PATCH 07/16] feat: Enhance NodemonService with process management and cleanup - Implemented stopPm2IfRunning method to stop any running pm2-managed instances of unraid-api before starting nodemon. - Added findMatchingNodemonPids method to identify existing nodemon processes, improving resource management. - Updated start method to handle scenarios where a stored pid is running or dead, ensuring proper cleanup and logging. - Introduced new unit tests to validate the new functionality and ensure robustness in process handling. --- .../unraid-api/cli/nodemon.service.spec.ts | 87 +++++++++++++++++++ api/src/unraid-api/cli/nodemon.service.ts | 77 +++++++++++++++- 2 files changed, 162 insertions(+), 2 deletions(-) diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index 6bfd7c3ede..1834d83929 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -40,6 +40,14 @@ describe('NodemonService', () => { const mockWriteFile = vi.mocked(fs.writeFile); const mockRm = vi.mocked(fs.rm); const killSpy = vi.spyOn(process, 'kill'); + const stopPm2Spy = vi.spyOn( + NodemonService.prototype as unknown as { stopPm2IfRunning: () => Promise }, + 'stopPm2IfRunning' + ); + const findMatchingSpy = vi.spyOn( + NodemonService.prototype as unknown as { findMatchingNodemonPids: () => Promise }, + 'findMatchingNodemonPids' + ); beforeEach(() => { vi.clearAllMocks(); @@ -48,6 +56,8 @@ describe('NodemonService', () => { mockRm.mockResolvedValue(undefined as unknown as void); vi.mocked(fileExists).mockResolvedValue(false); killSpy.mockReturnValue(true); + findMatchingSpy.mockResolvedValue([]); + stopPm2Spy.mockResolvedValue(); }); it('ensures directories needed by nodemon exist', async () => { @@ -84,9 +94,11 @@ describe('NodemonService', () => { unref, } as unknown as ReturnType); killSpy.mockReturnValue(true); + findMatchingSpy.mockResolvedValue([]); await service.start({ env: { LOG_LEVEL: 'DEBUG' } }); + expect(stopPm2Spy).toHaveBeenCalled(); expect(execa).toHaveBeenCalledWith( '/usr/bin/nodemon', ['--config', '/etc/unraid-api/nodemon.json', '--quiet'], @@ -185,6 +197,81 @@ describe('NodemonService', () => { expect(logsSpy).toHaveBeenCalledWith(50); }); + it('is a no-op when a recorded nodemon pid is already running', async () => { + const service = new NodemonService(logger); + vi.spyOn( + service as unknown as { getStoredPid: () => Promise }, + 'getStoredPid' + ).mockResolvedValue(999); + vi.spyOn( + service as unknown as { isPidRunning: (pid: number) => Promise }, + 'isPidRunning' + ).mockResolvedValue(true); + + await service.start(); + + expect(logger.info).toHaveBeenCalledWith( + 'unraid-api already running under nodemon (pid 999); skipping start.' + ); + expect(execa).not.toHaveBeenCalled(); + expect(mockRm).not.toHaveBeenCalled(); + }); + + it('removes stale pid file and starts when recorded pid is dead', async () => { + const service = new NodemonService(logger); + const logStream = { pipe: vi.fn(), close: vi.fn() }; + vi.mocked(createWriteStream).mockReturnValue( + logStream as unknown as ReturnType + ); + const stdout = { pipe: vi.fn() }; + const stderr = { pipe: vi.fn() }; + const unref = vi.fn(); + vi.mocked(execa).mockReturnValue({ + pid: 111, + stdout, + stderr, + unref, + } as unknown as ReturnType); + vi.spyOn( + service as unknown as { getStoredPid: () => Promise }, + 'getStoredPid' + ).mockResolvedValue(555); + vi.spyOn( + service as unknown as { isPidRunning: (pid: number) => Promise }, + 'isPidRunning' + ) + .mockResolvedValueOnce(false) + .mockResolvedValue(true); + vi.spyOn(service, 'logs').mockResolvedValue('recent log lines'); + findMatchingSpy.mockResolvedValue([]); + + await service.start(); + + expect(mockRm).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', { force: true }); + expect(execa).toHaveBeenCalled(); + expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '111'); + expect(logger.warn).toHaveBeenCalledWith( + 'Found nodemon pid file (555) but the process is not running. Cleaning up.' + ); + }); + + it('adopts an already-running nodemon when no pid file exists', async () => { + const service = new NodemonService(logger); + findMatchingSpy.mockResolvedValue([888]); + vi.spyOn( + service as unknown as { isPidRunning: (pid: number) => Promise }, + 'isPidRunning' + ).mockResolvedValue(true); + + await service.start(); + + expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '888'); + expect(logger.info).toHaveBeenCalledWith( + 'unraid-api already running under nodemon (pid 888); discovered via process scan.' + ); + expect(execa).not.toHaveBeenCalled(); + }); + it('returns not running when pid file is missing', async () => { const service = new NodemonService(logger); vi.mocked(fileExists).mockResolvedValue(false); diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index e1faf3f30e..1b5db069bf 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -1,7 +1,8 @@ import { Injectable } from '@nestjs/common'; import { createWriteStream } from 'node:fs'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; import { execa } from 'execa'; @@ -36,6 +37,36 @@ export class NodemonService { await mkdir(dirname(NODEMON_PID_PATH), { recursive: true }); } + private async stopPm2IfRunning() { + const pm2PidPath = '/var/log/.pm2/pm2.pid'; + if (!(await fileExists(pm2PidPath))) return; + + const pm2Candidates = ['/usr/bin/pm2', '/usr/local/bin/pm2']; + const pm2Path = + ( + await Promise.all( + pm2Candidates.map(async (candidate) => + (await fileExists(candidate)) ? candidate : null + ) + ) + ).find(Boolean) ?? null; + + if (!pm2Path) return; + + try { + const { stdout } = await execa(pm2Path, ['jlist']); + const processes = JSON.parse(stdout); + const hasUnraid = + Array.isArray(processes) && processes.some((proc) => proc?.name === 'unraid-api'); + if (!hasUnraid) return; + await execa(pm2Path, ['delete', 'unraid-api']); + this.logger.info('Stopped pm2-managed unraid-api before starting nodemon.'); + } catch (error) { + // PM2 may not be installed or responding; keep this quiet to avoid noisy startup. + this.logger.debug?.('Skipping pm2 cleanup (not installed or not running).'); + } + } + private async getStoredPid(): Promise { if (!(await fileExists(NODEMON_PID_PATH))) return null; const contents = (await readFile(NODEMON_PID_PATH, 'utf-8')).trim(); @@ -52,6 +83,23 @@ export class NodemonService { } } + private async findMatchingNodemonPids(): Promise { + try { + const { stdout } = await execa('ps', ['-eo', 'pid,args']); + return stdout + .split('\n') + .map((line) => line.trim()) + .map((line) => line.match(/^(\d+)\s+(.*)$/)) + .filter((match): match is RegExpMatchArray => Boolean(match)) + .map(([, pid, cmd]) => ({ pid: Number.parseInt(pid, 10), cmd })) + .filter(({ cmd }) => cmd.includes('nodemon') && cmd.includes(NODEMON_CONFIG_PATH)) + .map(({ pid }) => pid) + .filter((pid) => Number.isInteger(pid)); + } catch { + return []; + } + } + async start(options: StartOptions = {}) { try { await this.ensureNodemonDependencies(); @@ -62,7 +110,32 @@ export class NodemonService { throw error; } - await this.stop({ quiet: true }); + await this.stopPm2IfRunning(); + + const existingPid = await this.getStoredPid(); + if (existingPid) { + const running = await this.isPidRunning(existingPid); + if (running) { + this.logger.info( + `unraid-api already running under nodemon (pid ${existingPid}); skipping start.` + ); + return; + } + this.logger.warn( + `Found nodemon pid file (${existingPid}) but the process is not running. Cleaning up.` + ); + await rm(NODEMON_PID_PATH, { force: true }); + } + + const discoveredPids = await this.findMatchingNodemonPids(); + const discoveredPid = discoveredPids.at(0); + if (discoveredPid && (await this.isPidRunning(discoveredPid))) { + await writeFile(NODEMON_PID_PATH, `${discoveredPid}`); + this.logger.info( + `unraid-api already running under nodemon (pid ${discoveredPid}); discovered via process scan.` + ); + return; + } const overrides = Object.fromEntries( Object.entries(options.env ?? {}).filter(([, value]) => value !== undefined) From 9253250dc5d732def337aa760178e1e03ce37c87 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 21 Nov 2025 21:55:59 -0500 Subject: [PATCH 08/16] feat: Add process termination and management in NodemonService - Implemented findDirectMainPids and terminatePids methods to identify and terminate existing unraid-api processes before starting nodemon. - Enhanced the start method to include checks for running processes, ensuring proper cleanup and logging. - Updated unit tests to validate the new process management functionality, improving overall robustness. --- .../unraid-api/cli/nodemon.service.spec.ts | 39 ++++++++++ api/src/unraid-api/cli/nodemon.service.ts | 76 ++++++++++++++++--- 2 files changed, 103 insertions(+), 12 deletions(-) diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index 1834d83929..f29803b621 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -48,6 +48,14 @@ describe('NodemonService', () => { NodemonService.prototype as unknown as { findMatchingNodemonPids: () => Promise }, 'findMatchingNodemonPids' ); + const findDirectMainSpy = vi.spyOn( + NodemonService.prototype as unknown as { findDirectMainPids: () => Promise }, + 'findDirectMainPids' + ); + const terminateSpy = vi.spyOn( + NodemonService.prototype as unknown as { terminatePids: (pids: number[]) => Promise }, + 'terminatePids' + ); beforeEach(() => { vi.clearAllMocks(); @@ -57,6 +65,8 @@ describe('NodemonService', () => { vi.mocked(fileExists).mockResolvedValue(false); killSpy.mockReturnValue(true); findMatchingSpy.mockResolvedValue([]); + findDirectMainSpy.mockResolvedValue([]); + terminateSpy.mockResolvedValue(); stopPm2Spy.mockResolvedValue(); }); @@ -272,6 +282,35 @@ describe('NodemonService', () => { expect(execa).not.toHaveBeenCalled(); }); + it('terminates direct main.js processes before starting nodemon', async () => { + const service = new NodemonService(logger); + findMatchingSpy.mockResolvedValue([]); + findDirectMainSpy.mockResolvedValue([321, 654]); + + const logStream = { pipe: vi.fn(), close: vi.fn() }; + vi.mocked(createWriteStream).mockReturnValue( + logStream as unknown as ReturnType + ); + const stdout = { pipe: vi.fn() }; + const stderr = { pipe: vi.fn() }; + const unref = vi.fn(); + vi.mocked(execa).mockReturnValue({ + pid: 777, + stdout, + stderr, + unref, + } as unknown as ReturnType); + + await service.start(); + + expect(terminateSpy).toHaveBeenCalledWith([321, 654]); + expect(execa).toHaveBeenCalledWith( + '/usr/bin/nodemon', + ['--config', '/etc/unraid-api/nodemon.json', '--quiet'], + expect.objectContaining({ cwd: '/usr/local/unraid-api' }) + ); + }); + it('returns not running when pid file is missing', async () => { const service = new NodemonService(logger); vi.mocked(fileExists).mockResolvedValue(false); diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index 1b5db069bf..b6971f3af0 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { createWriteStream } from 'node:fs'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { execa } from 'execa'; @@ -51,20 +50,34 @@ export class NodemonService { ) ).find(Boolean) ?? null; - if (!pm2Path) return; + if (pm2Path) { + try { + const { stdout } = await execa(pm2Path, ['jlist']); + const processes = JSON.parse(stdout); + const hasUnraid = + Array.isArray(processes) && processes.some((proc) => proc?.name === 'unraid-api'); + if (hasUnraid) { + await execa(pm2Path, ['delete', 'unraid-api']); + this.logger.info('Stopped pm2-managed unraid-api before starting nodemon.'); + } + } catch (error) { + // PM2 may not be installed or responding; keep this quiet to avoid noisy startup. + this.logger.debug?.('Skipping pm2 cleanup (not installed or not running).'); + } + } + // Fallback: directly kill the pm2 daemon and remove its state, even if pm2 binary is missing. try { - const { stdout } = await execa(pm2Path, ['jlist']); - const processes = JSON.parse(stdout); - const hasUnraid = - Array.isArray(processes) && processes.some((proc) => proc?.name === 'unraid-api'); - if (!hasUnraid) return; - await execa(pm2Path, ['delete', 'unraid-api']); - this.logger.info('Stopped pm2-managed unraid-api before starting nodemon.'); - } catch (error) { - // PM2 may not be installed or responding; keep this quiet to avoid noisy startup. - this.logger.debug?.('Skipping pm2 cleanup (not installed or not running).'); + const pidText = (await readFile(pm2PidPath, 'utf-8')).trim(); + const pid = Number.parseInt(pidText, 10); + if (!Number.isNaN(pid)) { + process.kill(pid, 'SIGTERM'); + this.logger.debug?.(`Sent SIGTERM to pm2 daemon (pid ${pid}).`); + } + } catch { + // ignore } + await rm('/var/log/.pm2', { recursive: true, force: true }); } private async getStoredPid(): Promise { @@ -100,6 +113,37 @@ export class NodemonService { } } + private async findDirectMainPids(): Promise { + try { + const mainPath = join(UNRAID_API_CWD, 'dist', 'main.js'); + const { stdout } = await execa('ps', ['-eo', 'pid,args']); + return stdout + .split('\n') + .map((line) => line.trim()) + .map((line) => line.match(/^(\d+)\s+(.*)$/)) + .filter((match): match is RegExpMatchArray => Boolean(match)) + .map(([, pid, cmd]) => ({ pid: Number.parseInt(pid, 10), cmd })) + .filter(({ cmd }) => cmd.includes(mainPath)) + .map(({ pid }) => pid) + .filter((pid) => Number.isInteger(pid)); + } catch { + return []; + } + } + + private async terminatePids(pids: number[]) { + for (const pid of pids) { + try { + process.kill(pid, 'SIGTERM'); + this.logger.debug?.(`Sent SIGTERM to existing unraid-api process (pid ${pid}).`); + } catch (error) { + this.logger.debug?.( + `Failed to send SIGTERM to pid ${pid}: ${error instanceof Error ? error.message : error}` + ); + } + } + } + async start(options: StartOptions = {}) { try { await this.ensureNodemonDependencies(); @@ -137,6 +181,14 @@ export class NodemonService { return; } + const directMainPids = await this.findDirectMainPids(); + if (directMainPids.length > 0) { + this.logger.warn( + `Found existing unraid-api process(es) running directly: ${directMainPids.join(', ')}. Stopping them before starting nodemon.` + ); + await this.terminatePids(directMainPids); + } + const overrides = Object.fromEntries( Object.entries(options.env ?? {}).filter(([, value]) => value !== undefined) ); From a5e9b833743aa29f595a98596afee385bd9db8e7 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 21 Nov 2025 22:16:32 -0500 Subject: [PATCH 09/16] feat: Implement waitForNodemonExit method and enhance restart logic in NodemonService - Added waitForNodemonExit method to ensure nodemon processes are fully terminated before restarting. - Updated restart method to call waitForNodemonExit, improving process management during restarts. - Introduced a new unit test to validate the behavior of the restart method, ensuring proper sequence of stop, wait, and start operations. --- api/src/unraid-api/cli/nodemon.service.spec.ts | 18 ++++++++++++++++++ api/src/unraid-api/cli/nodemon.service.ts | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index f29803b621..cd317a7e85 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -360,4 +360,22 @@ describe('NodemonService', () => { ); expect(result).toBe(''); }); + + it('waits for nodemon to exit during restart before starting again', async () => { + const service = new NodemonService(logger); + const stopSpy = vi.spyOn(service, 'stop').mockResolvedValue(); + const startSpy = vi.spyOn(service, 'start').mockResolvedValue(); + const waitSpy = vi + .spyOn( + service as unknown as { waitForNodemonExit: () => Promise }, + 'waitForNodemonExit' + ) + .mockResolvedValue(); + + await service.restart({ env: { LOG_LEVEL: 'DEBUG' } }); + + expect(stopSpy).toHaveBeenCalledWith({ quiet: true }); + expect(waitSpy).toHaveBeenCalled(); + expect(startSpy).toHaveBeenCalledWith({ env: { LOG_LEVEL: 'DEBUG' } }); + }); }); diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index b6971f3af0..41da0468d1 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -144,6 +144,23 @@ export class NodemonService { } } + private async waitForNodemonExit(timeoutMs = 5000, pollIntervalMs = 100) { + const deadline = Date.now() + timeoutMs; + + // Poll for any remaining nodemon processes that match our config file + while (Date.now() < deadline) { + const pids = await this.findMatchingNodemonPids(); + if (pids.length === 0) return; + + const runningFlags = await Promise.all(pids.map((pid) => this.isPidRunning(pid))); + if (!runningFlags.some(Boolean)) return; + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + this.logger.debug?.('Timed out waiting for nodemon to exit; continuing restart anyway.'); + } + async start(options: StartOptions = {}) { try { await this.ensureNodemonDependencies(); @@ -256,6 +273,7 @@ export class NodemonService { async restart(options: StartOptions = {}) { await this.stop({ quiet: true }); + await this.waitForNodemonExit(); await this.start(options); } From bec54e4feb50d968ef189efde3dffac1878d1df3 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sun, 23 Nov 2025 10:11:42 -0500 Subject: [PATCH 10/16] feat: Enhance NodemonService to improve process management during restarts - Updated the start method to restart nodemon if a recorded pid is already running, ensuring proper cleanup and logging. - Modified the restart method to delegate to start, streamlining the process management logic. - Enhanced unit tests to validate the new behavior, including scenarios for cleaning up stray processes and ensuring fresh starts. --- .../unraid-api/cli/nodemon.service.spec.ts | 101 +++++++++++++++--- api/src/unraid-api/cli/nodemon.service.ts | 30 +++--- 2 files changed, 106 insertions(+), 25 deletions(-) diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index cd317a7e85..4c94c82d78 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -207,8 +207,13 @@ describe('NodemonService', () => { expect(logsSpy).toHaveBeenCalledWith(50); }); - it('is a no-op when a recorded nodemon pid is already running', async () => { + it('restarts when a recorded nodemon pid is already running', async () => { const service = new NodemonService(logger); + const stopSpy = vi.spyOn(service, 'stop').mockResolvedValue(); + vi.spyOn( + service as unknown as { waitForNodemonExit: () => Promise }, + 'waitForNodemonExit' + ).mockResolvedValue(); vi.spyOn( service as unknown as { getStoredPid: () => Promise }, 'getStoredPid' @@ -218,13 +223,28 @@ describe('NodemonService', () => { 'isPidRunning' ).mockResolvedValue(true); + const logStream = { pipe: vi.fn(), close: vi.fn() }; + vi.mocked(createWriteStream).mockReturnValue( + logStream as unknown as ReturnType + ); + const stdout = { pipe: vi.fn() }; + const stderr = { pipe: vi.fn() }; + const unref = vi.fn(); + vi.mocked(execa).mockReturnValue({ + pid: 456, + stdout, + stderr, + unref, + } as unknown as ReturnType); + await service.start(); + expect(stopSpy).toHaveBeenCalledWith({ quiet: true }); + expect(mockRm).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', { force: true }); + expect(execa).toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith( - 'unraid-api already running under nodemon (pid 999); skipping start.' + 'unraid-api already running under nodemon (pid 999); restarting for a fresh start.' ); - expect(execa).not.toHaveBeenCalled(); - expect(mockRm).not.toHaveBeenCalled(); }); it('removes stale pid file and starts when recorded pid is dead', async () => { @@ -265,21 +285,36 @@ describe('NodemonService', () => { ); }); - it('adopts an already-running nodemon when no pid file exists', async () => { + it('cleans up stray nodemon when no pid file exists', async () => { const service = new NodemonService(logger); findMatchingSpy.mockResolvedValue([888]); vi.spyOn( service as unknown as { isPidRunning: (pid: number) => Promise }, 'isPidRunning' ).mockResolvedValue(true); + vi.spyOn( + service as unknown as { waitForNodemonExit: () => Promise }, + 'waitForNodemonExit' + ).mockResolvedValue(); + + const logStream = { pipe: vi.fn(), close: vi.fn() }; + vi.mocked(createWriteStream).mockReturnValue( + logStream as unknown as ReturnType + ); + const stdout = { pipe: vi.fn() }; + const stderr = { pipe: vi.fn() }; + const unref = vi.fn(); + vi.mocked(execa).mockReturnValue({ + pid: 222, + stdout, + stderr, + unref, + } as unknown as ReturnType); await service.start(); - expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '888'); - expect(logger.info).toHaveBeenCalledWith( - 'unraid-api already running under nodemon (pid 888); discovered via process scan.' - ); - expect(execa).not.toHaveBeenCalled(); + expect(terminateSpy).toHaveBeenCalledWith([888]); + expect(execa).toHaveBeenCalled(); }); it('terminates direct main.js processes before starting nodemon', async () => { @@ -364,18 +399,60 @@ describe('NodemonService', () => { it('waits for nodemon to exit during restart before starting again', async () => { const service = new NodemonService(logger); const stopSpy = vi.spyOn(service, 'stop').mockResolvedValue(); - const startSpy = vi.spyOn(service, 'start').mockResolvedValue(); const waitSpy = vi .spyOn( service as unknown as { waitForNodemonExit: () => Promise }, 'waitForNodemonExit' ) .mockResolvedValue(); + vi.spyOn( + service as unknown as { getStoredPid: () => Promise }, + 'getStoredPid' + ).mockResolvedValue(123); + vi.spyOn( + service as unknown as { isPidRunning: (pid: number) => Promise }, + 'isPidRunning' + ).mockResolvedValue(true); + const logStream = { pipe: vi.fn(), close: vi.fn() }; + vi.mocked(createWriteStream).mockReturnValue( + logStream as unknown as ReturnType + ); + const stdout = { pipe: vi.fn() }; + const stderr = { pipe: vi.fn() }; + const unref = vi.fn(); + vi.mocked(execa).mockReturnValue({ + pid: 456, + stdout, + stderr, + unref, + } as unknown as ReturnType); await service.restart({ env: { LOG_LEVEL: 'DEBUG' } }); expect(stopSpy).toHaveBeenCalledWith({ quiet: true }); expect(waitSpy).toHaveBeenCalled(); - expect(startSpy).toHaveBeenCalledWith({ env: { LOG_LEVEL: 'DEBUG' } }); + expect(execa).toHaveBeenCalled(); + }); + + it('performs clean start on restart when nodemon is not running', async () => { + const service = new NodemonService(logger); + const stopSpy = vi.spyOn(service, 'stop').mockResolvedValue(); + const startSpy = vi.spyOn(service, 'start').mockResolvedValue(); + const waitSpy = vi + .spyOn( + service as unknown as { waitForNodemonExit: () => Promise }, + 'waitForNodemonExit' + ) + .mockResolvedValue(); + vi.spyOn( + service as unknown as { getStoredPid: () => Promise }, + 'getStoredPid' + ).mockResolvedValue(null); + + await service.restart(); + + expect(stopSpy).not.toHaveBeenCalled(); + expect(waitSpy).not.toHaveBeenCalled(); + expect(startSpy).toHaveBeenCalled(); }); }); diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index 41da0468d1..57a1344214 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -178,24 +178,29 @@ export class NodemonService { const running = await this.isPidRunning(existingPid); if (running) { this.logger.info( - `unraid-api already running under nodemon (pid ${existingPid}); skipping start.` + `unraid-api already running under nodemon (pid ${existingPid}); restarting for a fresh start.` ); - return; + await this.stop({ quiet: true }); + await this.waitForNodemonExit(); + await rm(NODEMON_PID_PATH, { force: true }); + } else { + this.logger.warn( + `Found nodemon pid file (${existingPid}) but the process is not running. Cleaning up.` + ); + await rm(NODEMON_PID_PATH, { force: true }); } - this.logger.warn( - `Found nodemon pid file (${existingPid}) but the process is not running. Cleaning up.` - ); - await rm(NODEMON_PID_PATH, { force: true }); } const discoveredPids = await this.findMatchingNodemonPids(); - const discoveredPid = discoveredPids.at(0); - if (discoveredPid && (await this.isPidRunning(discoveredPid))) { - await writeFile(NODEMON_PID_PATH, `${discoveredPid}`); + const liveDiscoveredPids = await Promise.all( + discoveredPids.map(async (pid) => ((await this.isPidRunning(pid)) ? pid : null)) + ).then((pids) => pids.filter((pid): pid is number => pid !== null)); + if (liveDiscoveredPids.length > 0) { this.logger.info( - `unraid-api already running under nodemon (pid ${discoveredPid}); discovered via process scan.` + `Found nodemon process(es) (${liveDiscoveredPids.join(', ')}) without a pid file; restarting for a fresh start.` ); - return; + await this.terminatePids(liveDiscoveredPids); + await this.waitForNodemonExit(); } const directMainPids = await this.findDirectMainPids(); @@ -272,8 +277,7 @@ export class NodemonService { } async restart(options: StartOptions = {}) { - await this.stop({ quiet: true }); - await this.waitForNodemonExit(); + // Delegate to start so both commands share identical logic await this.start(options); } From dc7a449f3ff51594ecb90a23616ebf54881cf50e Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 25 Nov 2025 10:40:52 -0500 Subject: [PATCH 11/16] fix: Update log stream handling in NodemonService tests - Modified the log stream mock to use a file descriptor instead of pipe methods, aligning with the actual implementation in NodemonService. - Removed unnecessary stdout and stderr pipe mocks from unit tests, simplifying the test setup while maintaining functionality. - Ensured consistency between the test and implementation for improved clarity and maintainability. --- .../unraid-api/cli/nodemon.service.spec.ts | 56 ++++--------------- api/src/unraid-api/cli/nodemon.service.ts | 4 +- 2 files changed, 12 insertions(+), 48 deletions(-) diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index 4c94c82d78..ba8bfc3bef 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -8,7 +8,7 @@ import { fileExists } from '@app/core/utils/files/file-exists.js'; import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; vi.mock('node:fs', () => ({ - createWriteStream: vi.fn(() => ({ pipe: vi.fn(), close: vi.fn() })), + createWriteStream: vi.fn(() => ({ fd: 42, close: vi.fn() })), })); vi.mock('node:fs/promises'); vi.mock('execa', () => ({ execa: vi.fn() })); @@ -90,17 +90,13 @@ describe('NodemonService', () => { it('starts nodemon and writes pid file', async () => { const service = new NodemonService(logger); - const logStream = { pipe: vi.fn(), close: vi.fn() }; + const logStream = { fd: 99, close: vi.fn() }; vi.mocked(createWriteStream).mockReturnValue( logStream as unknown as ReturnType ); - const stdout = { pipe: vi.fn() }; - const stderr = { pipe: vi.fn() }; const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 123, - stdout, - stderr, unref, } as unknown as ReturnType); killSpy.mockReturnValue(true); @@ -116,12 +112,10 @@ describe('NodemonService', () => { cwd: '/usr/local/unraid-api', env: expect.objectContaining({ LOG_LEVEL: 'DEBUG' }), detached: true, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['ignore', logStream, logStream], } ); expect(createWriteStream).toHaveBeenCalledWith('/var/log/graphql-api.log', { flags: 'a' }); - expect(stdout.pipe).toHaveBeenCalled(); - expect(stderr.pipe).toHaveBeenCalled(); expect(unref).toHaveBeenCalled(); expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '123'); expect(logger.info).toHaveBeenCalledWith('Started nodemon (pid 123)'); @@ -142,7 +136,7 @@ describe('NodemonService', () => { it('throws error and closes logStream when execa fails', async () => { const service = new NodemonService(logger); - const logStream = { pipe: vi.fn(), close: vi.fn() }; + const logStream = { fd: 99, close: vi.fn() }; vi.mocked(createWriteStream).mockReturnValue( logStream as unknown as ReturnType ); @@ -159,17 +153,13 @@ describe('NodemonService', () => { it('throws error and closes logStream when pid is missing', async () => { const service = new NodemonService(logger); - const logStream = { pipe: vi.fn(), close: vi.fn() }; + const logStream = { fd: 99, close: vi.fn() }; vi.mocked(createWriteStream).mockReturnValue( logStream as unknown as ReturnType ); - const stdout = { pipe: vi.fn() }; - const stderr = { pipe: vi.fn() }; const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: undefined, - stdout, - stderr, unref, } as unknown as ReturnType); @@ -183,17 +173,13 @@ describe('NodemonService', () => { it('throws when nodemon exits immediately after start', async () => { const service = new NodemonService(logger); - const logStream = { pipe: vi.fn(), close: vi.fn() }; + const logStream = { fd: 99, close: vi.fn() }; vi.mocked(createWriteStream).mockReturnValue( logStream as unknown as ReturnType ); - const stdout = { pipe: vi.fn() }; - const stderr = { pipe: vi.fn() }; const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 456, - stdout, - stderr, unref, } as unknown as ReturnType); killSpy.mockImplementation(() => { @@ -223,17 +209,13 @@ describe('NodemonService', () => { 'isPidRunning' ).mockResolvedValue(true); - const logStream = { pipe: vi.fn(), close: vi.fn() }; + const logStream = { fd: 99, close: vi.fn() }; vi.mocked(createWriteStream).mockReturnValue( logStream as unknown as ReturnType ); - const stdout = { pipe: vi.fn() }; - const stderr = { pipe: vi.fn() }; const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 456, - stdout, - stderr, unref, } as unknown as ReturnType); @@ -249,17 +231,13 @@ describe('NodemonService', () => { it('removes stale pid file and starts when recorded pid is dead', async () => { const service = new NodemonService(logger); - const logStream = { pipe: vi.fn(), close: vi.fn() }; + const logStream = { fd: 99, close: vi.fn() }; vi.mocked(createWriteStream).mockReturnValue( logStream as unknown as ReturnType ); - const stdout = { pipe: vi.fn() }; - const stderr = { pipe: vi.fn() }; const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 111, - stdout, - stderr, unref, } as unknown as ReturnType); vi.spyOn( @@ -297,17 +275,13 @@ describe('NodemonService', () => { 'waitForNodemonExit' ).mockResolvedValue(); - const logStream = { pipe: vi.fn(), close: vi.fn() }; + const logStream = { fd: 99, close: vi.fn() }; vi.mocked(createWriteStream).mockReturnValue( logStream as unknown as ReturnType ); - const stdout = { pipe: vi.fn() }; - const stderr = { pipe: vi.fn() }; const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 222, - stdout, - stderr, unref, } as unknown as ReturnType); @@ -322,17 +296,13 @@ describe('NodemonService', () => { findMatchingSpy.mockResolvedValue([]); findDirectMainSpy.mockResolvedValue([321, 654]); - const logStream = { pipe: vi.fn(), close: vi.fn() }; + const logStream = { fd: 99, close: vi.fn() }; vi.mocked(createWriteStream).mockReturnValue( logStream as unknown as ReturnType ); - const stdout = { pipe: vi.fn() }; - const stderr = { pipe: vi.fn() }; const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 777, - stdout, - stderr, unref, } as unknown as ReturnType); @@ -413,17 +383,13 @@ describe('NodemonService', () => { service as unknown as { isPidRunning: (pid: number) => Promise }, 'isPidRunning' ).mockResolvedValue(true); - const logStream = { pipe: vi.fn(), close: vi.fn() }; + const logStream = { fd: 99, close: vi.fn() }; vi.mocked(createWriteStream).mockReturnValue( logStream as unknown as ReturnType ); - const stdout = { pipe: vi.fn() }; - const stderr = { pipe: vi.fn() }; const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 456, - stdout, - stderr, unref, } as unknown as ReturnType); diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index 57a1344214..9f9c95073b 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -223,11 +223,9 @@ export class NodemonService { cwd: UNRAID_API_CWD, env, detached: true, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['ignore', logStream, logStream], }); - nodemonProcess.stdout?.pipe(logStream); - nodemonProcess.stderr?.pipe(logStream); nodemonProcess.unref(); if (!nodemonProcess.pid) { From 3462e7688dac59a9b1ba69e703d628c1dd306241 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 25 Nov 2025 12:03:01 -0500 Subject: [PATCH 12/16] feat: Add integration tests for NodemonService with real nodemon execution - Introduced a new integration test file for NodemonService to validate the start and stop functionality of the real nodemon process. - Implemented setup and teardown logic to create temporary files and directories for testing. - Enhanced logging and error handling in the tests to ensure proper verification of nodemon's behavior during execution. --- .../cli/nodemon.service.integration.spec.ts | 122 ++++++++++++++++++ .../unraid-api/cli/nodemon.service.spec.ts | 102 +++++++++------ api/src/unraid-api/cli/nodemon.service.ts | 35 ++++- 3 files changed, 220 insertions(+), 39 deletions(-) create mode 100644 api/src/unraid-api/cli/nodemon.service.integration.spec.ts diff --git a/api/src/unraid-api/cli/nodemon.service.integration.spec.ts b/api/src/unraid-api/cli/nodemon.service.integration.spec.ts new file mode 100644 index 0000000000..6826684eec --- /dev/null +++ b/api/src/unraid-api/cli/nodemon.service.integration.spec.ts @@ -0,0 +1,122 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +const logger = { + trace: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + log: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +} as const; + +describe('NodemonService (real nodemon)', () => { + const tmpRoot = join(tmpdir(), 'nodemon-service-'); + let workdir: string; + let scriptPath: string; + let configPath: string; + let logPath: string; + let pidPath: string; + const nodemonPath = join(process.cwd(), 'node_modules', 'nodemon', 'bin', 'nodemon.js'); + + beforeAll(async () => { + workdir = await mkdtemp(tmpRoot); + scriptPath = join(workdir, 'app.js'); + configPath = join(workdir, 'nodemon.json'); + logPath = join(workdir, 'nodemon.log'); + pidPath = join(workdir, 'nodemon.pid'); + + await writeFile( + scriptPath, + ["console.log('nodemon-integration-start');", 'setInterval(() => {}, 1000);'].join('\n') + ); + + await writeFile( + configPath, + JSON.stringify( + { + watch: ['app.js'], + exec: 'node ./app.js', + signal: 'SIGTERM', + ext: 'js', + }, + null, + 2 + ) + ); + }); + + afterAll(async () => { + await rm(workdir, { recursive: true, force: true }); + }); + + it('starts and stops real nodemon and writes logs', async () => { + vi.resetModules(); + vi.doMock('@app/environment.js', () => ({ + LOG_LEVEL: 'INFO', + LOG_TYPE: 'pretty', + SUPPRESS_LOGS: false, + API_VERSION: 'test-version', + NODEMON_CONFIG_PATH: configPath, + NODEMON_PATH: nodemonPath, + NODEMON_PID_PATH: pidPath, + PATHS_LOGS_DIR: workdir, + PATHS_LOGS_FILE: logPath, + UNRAID_API_CWD: workdir, + })); + + const { NodemonService } = await import('./nodemon.service.js'); + const service = new NodemonService(logger); + + await service.start(); + + const pidText = (await readFile(pidPath, 'utf-8')).trim(); + const pid = Number.parseInt(pidText, 10); + expect(Number.isInteger(pid) && pid > 0).toBe(true); + + const logStats = await stat(logPath); + expect(logStats.isFile()).toBe(true); + await waitForLogEntry(logPath, 'nodemon-integration-start'); + + await service.stop(); + await waitForExit(pid); + await expect(stat(pidPath)).rejects.toThrow(); + }, 20_000); +}); + +async function waitForLogEntry(path: string, needle: string, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + + while (true) { + try { + const contents = await readFile(path, 'utf-8'); + if (contents.includes(needle)) return contents; + } catch { + // ignore until timeout + } + + if (Date.now() > deadline) { + throw new Error(`Log entry "${needle}" not found in ${path} within ${timeoutMs}ms`); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +async function waitForExit(pid: number, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + + while (true) { + try { + process.kill(pid, 0); + } catch { + return; + } + if (Date.now() > deadline) { + throw new Error(`Process ${pid} did not exit within ${timeoutMs}ms`); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index ba8bfc3bef..6ea92e4559 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -7,8 +7,37 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fileExists } from '@app/core/utils/files/file-exists.js'; import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; +const createLogStreamMock = (fd = 42, autoOpen = true) => { + const listeners: Record void>> = {}; + const stream: any = { + fd, + close: vi.fn(), + destroy: vi.fn(), + once: vi.fn(), + off: vi.fn(), + }; + + stream.once.mockImplementation((event: string, cb: (...args: any[]) => void) => { + listeners[event] = listeners[event] ?? []; + listeners[event].push(cb); + if (event === 'open' && autoOpen) cb(); + return stream; + }); + stream.off.mockImplementation((event: string, cb: (...args: any[]) => void) => { + listeners[event] = (listeners[event] ?? []).filter((fn) => fn !== cb); + return stream; + }); + stream.emit = (event: string, ...args: any[]) => { + (listeners[event] ?? []).forEach((fn) => fn(...args)); + }; + + return stream as ReturnType & { + emit: (event: string, ...args: any[]) => void; + }; +}; + vi.mock('node:fs', () => ({ - createWriteStream: vi.fn(() => ({ fd: 42, close: vi.fn() })), + createWriteStream: vi.fn(), })); vi.mock('node:fs/promises'); vi.mock('execa', () => ({ execa: vi.fn() })); @@ -59,6 +88,7 @@ describe('NodemonService', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(createWriteStream).mockImplementation(() => createLogStreamMock()); mockMkdir.mockResolvedValue(undefined); mockWriteFile.mockResolvedValue(undefined as unknown as void); mockRm.mockResolvedValue(undefined as unknown as void); @@ -76,6 +106,7 @@ describe('NodemonService', () => { await service.ensureNodemonDependencies(); expect(mockMkdir).toHaveBeenCalledWith('/var/log/unraid-api', { recursive: true }); + expect(mockMkdir).toHaveBeenCalledWith('/var/log', { recursive: true }); expect(mockMkdir).toHaveBeenCalledWith('/var/run/unraid-api', { recursive: true }); }); @@ -90,10 +121,8 @@ describe('NodemonService', () => { it('starts nodemon and writes pid file', async () => { const service = new NodemonService(logger); - const logStream = { fd: 99, close: vi.fn() }; - vi.mocked(createWriteStream).mockReturnValue( - logStream as unknown as ReturnType - ); + const logStream = createLogStreamMock(99); + vi.mocked(createWriteStream).mockReturnValue(logStream); const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 123, @@ -112,6 +141,7 @@ describe('NodemonService', () => { cwd: '/usr/local/unraid-api', env: expect.objectContaining({ LOG_LEVEL: 'DEBUG' }), detached: true, + reject: false, stdio: ['ignore', logStream, logStream], } ); @@ -136,10 +166,8 @@ describe('NodemonService', () => { it('throws error and closes logStream when execa fails', async () => { const service = new NodemonService(logger); - const logStream = { fd: 99, close: vi.fn() }; - vi.mocked(createWriteStream).mockReturnValue( - logStream as unknown as ReturnType - ); + const logStream = createLogStreamMock(99); + vi.mocked(createWriteStream).mockReturnValue(logStream); const error = new Error('Command not found'); vi.mocked(execa).mockImplementation(() => { throw error; @@ -151,12 +179,24 @@ describe('NodemonService', () => { expect(logger.info).not.toHaveBeenCalled(); }); - it('throws error and closes logStream when pid is missing', async () => { + it('throws a clear error when the log file cannot be opened', async () => { const service = new NodemonService(logger); - const logStream = { fd: 99, close: vi.fn() }; - vi.mocked(createWriteStream).mockReturnValue( - logStream as unknown as ReturnType + const logStream = createLogStreamMock(99, false); + vi.mocked(createWriteStream).mockReturnValue(logStream); + const openError = new Error('EACCES: permission denied'); + setTimeout(() => logStream.emit('error', openError), 0); + + await expect(service.start()).rejects.toThrow( + 'Failed to start nodemon: EACCES: permission denied' ); + expect(logStream.destroy).toHaveBeenCalled(); + expect(execa).not.toHaveBeenCalled(); + }); + + it('throws error and closes logStream when pid is missing', async () => { + const service = new NodemonService(logger); + const logStream = createLogStreamMock(99); + vi.mocked(createWriteStream).mockReturnValue(logStream); const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: undefined, @@ -173,10 +213,8 @@ describe('NodemonService', () => { it('throws when nodemon exits immediately after start', async () => { const service = new NodemonService(logger); - const logStream = { fd: 99, close: vi.fn() }; - vi.mocked(createWriteStream).mockReturnValue( - logStream as unknown as ReturnType - ); + const logStream = createLogStreamMock(99); + vi.mocked(createWriteStream).mockReturnValue(logStream); const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 456, @@ -209,10 +247,8 @@ describe('NodemonService', () => { 'isPidRunning' ).mockResolvedValue(true); - const logStream = { fd: 99, close: vi.fn() }; - vi.mocked(createWriteStream).mockReturnValue( - logStream as unknown as ReturnType - ); + const logStream = createLogStreamMock(99); + vi.mocked(createWriteStream).mockReturnValue(logStream); const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 456, @@ -231,10 +267,8 @@ describe('NodemonService', () => { it('removes stale pid file and starts when recorded pid is dead', async () => { const service = new NodemonService(logger); - const logStream = { fd: 99, close: vi.fn() }; - vi.mocked(createWriteStream).mockReturnValue( - logStream as unknown as ReturnType - ); + const logStream = createLogStreamMock(99); + vi.mocked(createWriteStream).mockReturnValue(logStream); const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 111, @@ -275,10 +309,8 @@ describe('NodemonService', () => { 'waitForNodemonExit' ).mockResolvedValue(); - const logStream = { fd: 99, close: vi.fn() }; - vi.mocked(createWriteStream).mockReturnValue( - logStream as unknown as ReturnType - ); + const logStream = createLogStreamMock(99); + vi.mocked(createWriteStream).mockReturnValue(logStream); const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 222, @@ -296,10 +328,8 @@ describe('NodemonService', () => { findMatchingSpy.mockResolvedValue([]); findDirectMainSpy.mockResolvedValue([321, 654]); - const logStream = { fd: 99, close: vi.fn() }; - vi.mocked(createWriteStream).mockReturnValue( - logStream as unknown as ReturnType - ); + const logStream = createLogStreamMock(99); + vi.mocked(createWriteStream).mockReturnValue(logStream); const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 777, @@ -383,10 +413,8 @@ describe('NodemonService', () => { service as unknown as { isPidRunning: (pid: number) => Promise }, 'isPidRunning' ).mockResolvedValue(true); - const logStream = { fd: 99, close: vi.fn() }; - vi.mocked(createWriteStream).mockReturnValue( - logStream as unknown as ReturnType - ); + const logStream = createLogStreamMock(99); + vi.mocked(createWriteStream).mockReturnValue(logStream); const unref = vi.fn(); vi.mocked(execa).mockReturnValue({ pid: 456, diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index 9f9c95073b..211984561f 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -33,6 +33,7 @@ export class NodemonService { async ensureNodemonDependencies() { await mkdir(PATHS_LOGS_DIR, { recursive: true }); + await mkdir(dirname(PATHS_LOGS_FILE), { recursive: true }); await mkdir(dirname(NODEMON_PID_PATH), { recursive: true }); } @@ -215,14 +216,17 @@ export class NodemonService { Object.entries(options.env ?? {}).filter(([, value]) => value !== undefined) ); const env = { ...process.env, ...overrides } as Record; - const logStream = createWriteStream(PATHS_LOGS_FILE, { flags: 'a' }); + let logStream: ReturnType | null = null; let nodemonProcess; try { + logStream = await this.createLogStream(); + nodemonProcess = execa(NODEMON_PATH, ['--config', NODEMON_CONFIG_PATH, '--quiet'], { cwd: UNRAID_API_CWD, env, detached: true, + reject: false, stdio: ['ignore', logStream, logStream], }); @@ -248,7 +252,7 @@ export class NodemonService { this.logger.info(`Started nodemon (pid ${nodemonProcess.pid})`); } catch (error) { - logStream.close(); + logStream?.close(); const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to start nodemon: ${errorMessage}`); } @@ -315,4 +319,31 @@ export class NodemonService { return ''; } } + + private async createLogStream() { + const logStream = createWriteStream(PATHS_LOGS_FILE, { flags: 'a' }); + + await new Promise((resolve, reject) => { + const cleanup = () => { + logStream.off('open', onOpen); + logStream.off('error', onError); + }; + + const onOpen = () => { + cleanup(); + resolve(); + }; + + const onError = (error: unknown) => { + cleanup(); + logStream.destroy(); + reject(error instanceof Error ? error : new Error(String(error))); + }; + + logStream.once('open', onOpen); + logStream.once('error', onError); + }); + + return logStream; + } } From fa837db09fabe1540e52461448886233f64fc0dc Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 25 Nov 2025 12:28:28 -0500 Subject: [PATCH 13/16] feat: Add nodemon log file configuration and enhance logging in NodemonService - Introduced PATHS_NODEMON_LOG_FILE to configure the log file for nodemon, allowing for better log management. - Updated log stream handling in NodemonService to write to the specified nodemon log file. - Enhanced integration tests to validate logging behavior and ensure proper file creation for both application and nodemon logs. --- api/src/core/log.ts | 6 +++-- api/src/environment.ts | 2 ++ .../cli/nodemon.service.integration.spec.ts | 26 ++++++++++++++----- .../unraid-api/cli/nodemon.service.spec.ts | 6 ++++- api/src/unraid-api/cli/nodemon.service.ts | 19 +++++++++++--- 5 files changed, 45 insertions(+), 14 deletions(-) diff --git a/api/src/core/log.ts b/api/src/core/log.ts index 4d0311b35a..c9a112aa96 100644 --- a/api/src/core/log.ts +++ b/api/src/core/log.ts @@ -1,7 +1,7 @@ import pino from 'pino'; import pretty from 'pino-pretty'; -import { API_VERSION, LOG_LEVEL, LOG_TYPE, SUPPRESS_LOGS } from '@app/environment.js'; +import { API_VERSION, LOG_LEVEL, LOG_TYPE, PATHS_LOGS_FILE, SUPPRESS_LOGS } from '@app/environment.js'; export const levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const; @@ -16,7 +16,9 @@ const nullDestination = pino.destination({ }); export const logDestination = - process.env.SUPPRESS_LOGS === 'true' ? nullDestination : pino.destination(); + process.env.SUPPRESS_LOGS === 'true' + ? nullDestination + : pino.destination({ dest: PATHS_LOGS_FILE, mkdir: true }); // Since process output is piped directly to the log file, we should not colorize stdout // to avoid ANSI escape codes in the log file const stream = SUPPRESS_LOGS diff --git a/api/src/environment.ts b/api/src/environment.ts index cef6282071..94107eab18 100644 --- a/api/src/environment.ts +++ b/api/src/environment.ts @@ -102,6 +102,8 @@ export const MOTHERSHIP_GRAPHQL_LINK = process.env.MOTHERSHIP_GRAPHQL_LINK export const PATHS_LOGS_DIR = process.env.PATHS_LOGS_DIR ?? process.env.LOGS_DIR ?? '/var/log/unraid-api'; export const PATHS_LOGS_FILE = process.env.PATHS_LOGS_FILE ?? '/var/log/graphql-api.log'; +export const PATHS_NODEMON_LOG_FILE = + process.env.PATHS_NODEMON_LOG_FILE ?? join(PATHS_LOGS_DIR, 'nodemon.log'); export const NODEMON_PATH = join(UNRAID_API_ROOT, 'node_modules', 'nodemon', 'bin', 'nodemon.js'); export const NODEMON_CONFIG_PATH = join(UNRAID_API_ROOT, 'nodemon.json'); diff --git a/api/src/unraid-api/cli/nodemon.service.integration.spec.ts b/api/src/unraid-api/cli/nodemon.service.integration.spec.ts index 6826684eec..9444faaa1f 100644 --- a/api/src/unraid-api/cli/nodemon.service.integration.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.integration.spec.ts @@ -18,7 +18,8 @@ describe('NodemonService (real nodemon)', () => { let workdir: string; let scriptPath: string; let configPath: string; - let logPath: string; + let appLogPath: string; + let nodemonLogPath: string; let pidPath: string; const nodemonPath = join(process.cwd(), 'node_modules', 'nodemon', 'bin', 'nodemon.js'); @@ -26,12 +27,21 @@ describe('NodemonService (real nodemon)', () => { workdir = await mkdtemp(tmpRoot); scriptPath = join(workdir, 'app.js'); configPath = join(workdir, 'nodemon.json'); - logPath = join(workdir, 'nodemon.log'); + appLogPath = join(workdir, 'app.log'); + nodemonLogPath = join(workdir, 'nodemon.log'); pidPath = join(workdir, 'nodemon.pid'); await writeFile( scriptPath, - ["console.log('nodemon-integration-start');", 'setInterval(() => {}, 1000);'].join('\n') + [ + "const { appendFileSync } = require('node:fs');", + "const appLog = process.env.PATHS_LOGS_FILE || './app.log';", + "const nodemonLog = process.env.PATHS_NODEMON_LOG_FILE || './nodemon.log';", + "appendFileSync(appLog, 'app-log-entry\\n');", + "appendFileSync(nodemonLog, 'nodemon-log-entry\\n');", + "console.log('nodemon-integration-start');", + 'setInterval(() => {}, 1000);', + ].join('\n') ); await writeFile( @@ -64,7 +74,8 @@ describe('NodemonService (real nodemon)', () => { NODEMON_PATH: nodemonPath, NODEMON_PID_PATH: pidPath, PATHS_LOGS_DIR: workdir, - PATHS_LOGS_FILE: logPath, + PATHS_LOGS_FILE: appLogPath, + PATHS_NODEMON_LOG_FILE: nodemonLogPath, UNRAID_API_CWD: workdir, })); @@ -77,9 +88,10 @@ describe('NodemonService (real nodemon)', () => { const pid = Number.parseInt(pidText, 10); expect(Number.isInteger(pid) && pid > 0).toBe(true); - const logStats = await stat(logPath); - expect(logStats.isFile()).toBe(true); - await waitForLogEntry(logPath, 'nodemon-integration-start'); + const nodemonLogStats = await stat(nodemonLogPath); + expect(nodemonLogStats.isFile()).toBe(true); + await waitForLogEntry(nodemonLogPath, 'Starting nodemon'); + await waitForLogEntry(appLogPath, 'app-log-entry'); await service.stop(); await waitForExit(pid); diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index 6ea92e4559..8f2f0386e6 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -13,6 +13,7 @@ const createLogStreamMock = (fd = 42, autoOpen = true) => { fd, close: vi.fn(), destroy: vi.fn(), + write: vi.fn(), once: vi.fn(), off: vi.fn(), }; @@ -52,6 +53,7 @@ vi.mock('@app/environment.js', () => ({ NODEMON_PID_PATH: '/var/run/unraid-api/nodemon.pid', PATHS_LOGS_DIR: '/var/log/unraid-api', PATHS_LOGS_FILE: '/var/log/graphql-api.log', + PATHS_NODEMON_LOG_FILE: '/var/log/unraid-api/nodemon.log', UNRAID_API_CWD: '/usr/local/unraid-api', })); @@ -145,7 +147,9 @@ describe('NodemonService', () => { stdio: ['ignore', logStream, logStream], } ); - expect(createWriteStream).toHaveBeenCalledWith('/var/log/graphql-api.log', { flags: 'a' }); + expect(createWriteStream).toHaveBeenCalledWith('/var/log/unraid-api/nodemon.log', { + flags: 'a', + }); expect(unref).toHaveBeenCalled(); expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '123'); expect(logger.info).toHaveBeenCalledWith('Started nodemon (pid 123)'); diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index 211984561f..c609854368 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -12,6 +12,7 @@ import { NODEMON_PID_PATH, PATHS_LOGS_DIR, PATHS_LOGS_FILE, + PATHS_NODEMON_LOG_FILE, UNRAID_API_CWD, } from '@app/environment.js'; import { LogService } from '@app/unraid-api/cli/log.service.js'; @@ -34,6 +35,7 @@ export class NodemonService { async ensureNodemonDependencies() { await mkdir(PATHS_LOGS_DIR, { recursive: true }); await mkdir(dirname(PATHS_LOGS_FILE), { recursive: true }); + await mkdir(dirname(PATHS_NODEMON_LOG_FILE), { recursive: true }); await mkdir(dirname(NODEMON_PID_PATH), { recursive: true }); } @@ -215,12 +217,21 @@ export class NodemonService { const overrides = Object.fromEntries( Object.entries(options.env ?? {}).filter(([, value]) => value !== undefined) ); - const env = { ...process.env, ...overrides } as Record; + const env = { + ...process.env, + PATHS_LOGS_FILE, + PATHS_NODEMON_LOG_FILE, + NODEMON_CONFIG_PATH, + NODEMON_PID_PATH, + UNRAID_API_CWD, + ...overrides, + } as Record; let logStream: ReturnType | null = null; let nodemonProcess; try { - logStream = await this.createLogStream(); + logStream = await this.createLogStream(PATHS_NODEMON_LOG_FILE); + logStream.write('Starting nodemon...\n'); nodemonProcess = execa(NODEMON_PATH, ['--config', NODEMON_CONFIG_PATH, '--quiet'], { cwd: UNRAID_API_CWD, @@ -320,8 +331,8 @@ export class NodemonService { } } - private async createLogStream() { - const logStream = createWriteStream(PATHS_LOGS_FILE, { flags: 'a' }); + private async createLogStream(logPath: string) { + const logStream = createWriteStream(logPath, { flags: 'a' }); await new Promise((resolve, reject) => { const cleanup = () => { From 9ff64629cf6ec34ba339beab3375b128c841d685 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 25 Nov 2025 13:59:09 -0500 Subject: [PATCH 14/16] chore: Update API version and refactor pubsub channel references - Updated API version in api.json from 4.25.3 to 4.27.2. - Refactored pubsub channel references across multiple files to use GRAPHQL_PUBSUB_CHANNEL instead of PUBSUB_CHANNEL, enhancing consistency and clarity in the codebase. - Adjusted related tests to ensure they align with the new pubsub channel structure. --- api/dev/configs/api.json | 2 +- api/src/core/pubsub.ts | 2 - .../store/listeners/array-event-listener.ts | 7 +- .../cli/nodemon.service.integration.spec.ts | 8 +- .../graph/resolvers/array/array.resolver.ts | 5 +- .../graph/resolvers/array/parity.resolver.ts | 4 +- .../display/display.resolver.spec.ts | 8 +- .../resolvers/display/display.resolver.ts | 5 +- .../docker/docker-event.service.spec.ts | 8 +- .../resolvers/docker/docker-event.service.ts | 5 +- .../resolvers/docker/docker.service.spec.ts | 9 +- .../graph/resolvers/docker/docker.service.ts | 7 +- .../metrics.resolver.integration.spec.ts | 51 ++++--- .../resolvers/metrics/metrics.resolver.ts | 25 ++-- .../loadNotificationsFile.test.ts | 2 +- .../notifications/notifications.resolver.ts | 7 +- .../notifications/notifications.service.ts | 7 +- .../graph/resolvers/owner/owner.resolver.ts | 5 +- .../resolvers/servers/server.resolver.ts | 5 +- .../subscription-helper.service.spec.ts | 131 +++++++++++------- .../services/subscription-helper.service.ts | 10 +- 21 files changed, 184 insertions(+), 129 deletions(-) diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index acaf5daa92..e09b0f3f55 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -1,5 +1,5 @@ { - "version": "4.25.3", + "version": "4.27.2", "extraOrigins": [], "sandbox": true, "ssoSubIds": [], diff --git a/api/src/core/pubsub.ts b/api/src/core/pubsub.ts index e3b679b86c..280614e5a4 100644 --- a/api/src/core/pubsub.ts +++ b/api/src/core/pubsub.ts @@ -7,8 +7,6 @@ import { PubSub } from 'graphql-subscriptions'; const eventEmitter = new EventEmitter(); eventEmitter.setMaxListeners(30); -export { GRAPHQL_PUBSUB_CHANNEL as PUBSUB_CHANNEL }; - export const pubsub = new PubSub({ eventEmitter }); /** diff --git a/api/src/store/listeners/array-event-listener.ts b/api/src/store/listeners/array-event-listener.ts index 6291a09195..70da63e80b 100644 --- a/api/src/store/listeners/array-event-listener.ts +++ b/api/src/store/listeners/array-event-listener.ts @@ -1,9 +1,10 @@ import { isAnyOf } from '@reduxjs/toolkit'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { isEqual } from 'lodash-es'; import { logger } from '@app/core/log.js'; import { getArrayData } from '@app/core/modules/array/get-array-data.js'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { startAppListening } from '@app/store/listeners/listener-middleware.js'; import { loadSingleStateFile } from '@app/store/modules/emhttp.js'; import { StateFileKey } from '@app/store/types.js'; @@ -20,14 +21,14 @@ export const enableArrayEventListener = () => await delay(5_000); const array = getArrayData(getState); if (!isEqual(oldArrayData, array)) { - pubsub.publish(PUBSUB_CHANNEL.ARRAY, { array }); + pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.ARRAY, { array }); logger.debug({ event: array }, 'Array was updated, publishing event'); } subscribe(); } else if (action.meta.arg === StateFileKey.var) { if (!isEqual(getOriginalState().emhttp.var?.name, getState().emhttp.var?.name)) { - await pubsub.publish(PUBSUB_CHANNEL.INFO, { + await pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.INFO, { info: { os: { hostname: getState().emhttp.var?.name, diff --git a/api/src/unraid-api/cli/nodemon.service.integration.spec.ts b/api/src/unraid-api/cli/nodemon.service.integration.spec.ts index 9444faaa1f..1d75ef9a2b 100644 --- a/api/src/unraid-api/cli/nodemon.service.integration.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.integration.spec.ts @@ -4,14 +4,20 @@ import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { LogService } from '@app/unraid-api/cli/log.service.js'; + const logger = { + clear: vi.fn(), + shouldLog: vi.fn(() => true), + table: vi.fn(), trace: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn(), info: vi.fn(), debug: vi.fn(), -} as const; + always: vi.fn(), +} as unknown as LogService; describe('NodemonService (real nodemon)', () => { const tmpRoot = join(tmpdir(), 'nodemon-service-'); diff --git a/api/src/unraid-api/graph/resolvers/array/array.resolver.ts b/api/src/unraid-api/graph/resolvers/array/array.resolver.ts index 40734973ea..45ad31932f 100644 --- a/api/src/unraid-api/graph/resolvers/array/array.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/array/array.resolver.ts @@ -1,9 +1,10 @@ import { Query, Resolver, Subscription } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; -import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { createSubscription } from '@app/core/pubsub.js'; import { UnraidArray } from '@app/unraid-api/graph/resolvers/array/array.model.js'; import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js'; @@ -26,6 +27,6 @@ export class ArrayResolver { resource: Resource.ARRAY, }) public async arraySubscription() { - return createSubscription(PUBSUB_CHANNEL.ARRAY); + return createSubscription(GRAPHQL_PUBSUB_CHANNEL.ARRAY); } } diff --git a/api/src/unraid-api/graph/resolvers/array/parity.resolver.ts b/api/src/unraid-api/graph/resolvers/array/parity.resolver.ts index 07b304c3c7..8ed56ab906 100644 --- a/api/src/unraid-api/graph/resolvers/array/parity.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/array/parity.resolver.ts @@ -1,10 +1,10 @@ import { Query, Resolver, Subscription } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { PubSub } from 'graphql-subscriptions'; -import { PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js'; import { ParityCheck } from '@app/unraid-api/graph/resolvers/array/parity.model.js'; import { ParityService } from '@app/unraid-api/graph/resolvers/array/parity.service.js'; @@ -33,6 +33,6 @@ export class ParityResolver { }) @Subscription(() => ParityCheck) parityHistorySubscription() { - return pubSub.asyncIterableIterator(PUBSUB_CHANNEL.PARITY); + return pubSub.asyncIterableIterator(GRAPHQL_PUBSUB_CHANNEL.PARITY); } } diff --git a/api/src/unraid-api/graph/resolvers/display/display.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/display/display.resolver.spec.ts index 884805059c..483a876237 100644 --- a/api/src/unraid-api/graph/resolvers/display/display.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/display/display.resolver.spec.ts @@ -1,6 +1,7 @@ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { DisplayResolver } from '@app/unraid-api/graph/resolvers/display/display.resolver.js'; @@ -9,9 +10,6 @@ import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/dis // Mock the pubsub module vi.mock('@app/core/pubsub.js', () => ({ createSubscription: vi.fn().mockReturnValue('mock-subscription'), - PUBSUB_CHANNEL: { - DISPLAY: 'display', - }, })); describe('DisplayResolver', () => { @@ -80,11 +78,11 @@ describe('DisplayResolver', () => { describe('displaySubscription', () => { it('should create and return subscription', async () => { - const { createSubscription, PUBSUB_CHANNEL } = await import('@app/core/pubsub.js'); + const { createSubscription } = await import('@app/core/pubsub.js'); const result = await resolver.displaySubscription(); - expect(createSubscription).toHaveBeenCalledWith(PUBSUB_CHANNEL.DISPLAY); + expect(createSubscription).toHaveBeenCalledWith(GRAPHQL_PUBSUB_CHANNEL.DISPLAY); expect(result).toBe('mock-subscription'); }); }); diff --git a/api/src/unraid-api/graph/resolvers/display/display.resolver.ts b/api/src/unraid-api/graph/resolvers/display/display.resolver.ts index 558c2b4be3..6f1e732763 100644 --- a/api/src/unraid-api/graph/resolvers/display/display.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/display/display.resolver.ts @@ -1,9 +1,10 @@ import { Query, Resolver, Subscription } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; -import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { createSubscription } from '@app/core/pubsub.js'; import { Display } from '@app/unraid-api/graph/resolvers/info/display/display.model.js'; import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js'; @@ -26,6 +27,6 @@ export class DisplayResolver { resource: Resource.DISPLAY, }) public async displaySubscription() { - return createSubscription(PUBSUB_CHANNEL.DISPLAY); + return createSubscription(GRAPHQL_PUBSUB_CHANNEL.DISPLAY); } } diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-event.service.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker-event.service.spec.ts index 933100f1bf..ab8823e08e 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-event.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-event.service.spec.ts @@ -2,11 +2,12 @@ import { Logger } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { PassThrough, Readable } from 'stream'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import Docker from 'dockerode'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Import pubsub for use in tests -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { DockerEventService } from '@app/unraid-api/graph/resolvers/docker/docker-event.service.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; @@ -46,9 +47,6 @@ vi.mock('@app/core/pubsub.js', () => ({ pubsub: { publish: vi.fn().mockResolvedValue(undefined), }, - PUBSUB_CHANNEL: { - INFO: 'info', - }, })); // Mock DockerService @@ -140,7 +138,7 @@ describe('DockerEventService', () => { expect(dockerService.clearContainerCache).toHaveBeenCalled(); expect(dockerService.getAppInfo).toHaveBeenCalled(); - expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.INFO, expect.any(Object)); + expect(pubsub.publish).toHaveBeenCalledWith(GRAPHQL_PUBSUB_CHANNEL.INFO, expect.any(Object)); }); it('should ignore non-watched actions', async () => { diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-event.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-event.service.ts index 8e34166b61..0be2febfcd 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-event.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-event.service.ts @@ -1,10 +1,11 @@ import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Readable } from 'stream'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { watch } from 'chokidar'; import Docker from 'dockerode'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { getters } from '@app/store/index.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; @@ -132,7 +133,7 @@ export class DockerEventService implements OnModuleDestroy, OnModuleInit { await this.dockerService.clearContainerCache(); // Get updated counts and publish const appInfo = await this.dockerService.getAppInfo(); - await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo); + await pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.INFO, appInfo); this.logger.debug(`Published app info update due to event: ${actionName}`); } } diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts index ba7e974f22..39843d2a22 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts @@ -2,11 +2,12 @@ import type { TestingModule } from '@nestjs/testing'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Test } from '@nestjs/testing'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import Docker from 'dockerode'; import { beforeEach, describe, expect, it, vi } from 'vitest'; // Import the mocked pubsub parts -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { ContainerState, DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; @@ -15,7 +16,7 @@ vi.mock('@app/core/pubsub.js', () => ({ pubsub: { publish: vi.fn().mockResolvedValue(undefined), }, - PUBSUB_CHANNEL: { + GRAPHQL_PUBSUB_CHANNEL: { INFO: 'info', }, })); @@ -274,7 +275,7 @@ describe('DockerService', () => { expect(mockCacheManager.del).toHaveBeenCalledWith(DockerService.CONTAINER_CACHE_KEY); expect(mockListContainers).toHaveBeenCalled(); expect(mockCacheManager.set).toHaveBeenCalled(); - expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.INFO, { + expect(pubsub.publish).toHaveBeenCalledWith(GRAPHQL_PUBSUB_CHANNEL.INFO, { info: { apps: { installed: 1, running: 1 }, }, @@ -332,7 +333,7 @@ describe('DockerService', () => { expect(mockCacheManager.del).toHaveBeenCalledWith(DockerService.CONTAINER_CACHE_KEY); expect(mockListContainers).toHaveBeenCalled(); expect(mockCacheManager.set).toHaveBeenCalled(); - expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.INFO, { + expect(pubsub.publish).toHaveBeenCalledWith(GRAPHQL_PUBSUB_CHANNEL.INFO, { info: { apps: { installed: 1, running: 0 }, }, diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker.service.ts index 5b244773f6..54bc9c88d2 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.service.ts @@ -2,10 +2,11 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { readFile } from 'fs/promises'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { type Cache } from 'cache-manager'; import Docker from 'dockerode'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { catchHandlers } from '@app/core/utils/misc/catch-handlers.js'; import { sleep } from '@app/core/utils/misc/sleep.js'; import { getters } from '@app/store/index.js'; @@ -210,7 +211,7 @@ export class DockerService { throw new Error(`Container ${id} not found after starting`); } const appInfo = await this.getAppInfo(); - await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo); + await pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.INFO, appInfo); return updatedContainer; } @@ -240,7 +241,7 @@ export class DockerService { this.logger.warn(`Container ${id} did not reach EXITED state after stop command.`); } const appInfo = await this.getAppInfo(); - await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo); + await pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.INFO, appInfo); return updatedContainer; } } diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts index 12b899a094..0c7fe074ab 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts @@ -2,9 +2,10 @@ import type { TestingModule } from '@nestjs/testing'; import { ScheduleModule } from '@nestjs/schedule'; import { Test } from '@nestjs/testing'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js'; import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js'; import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js'; @@ -107,7 +108,7 @@ describe('MetricsResolver Integration Tests', () => { }); // Trigger polling by simulating subscription - trackerService.subscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION); // Wait a bit for potential multiple executions await new Promise((resolve) => setTimeout(resolve, 100)); @@ -141,7 +142,7 @@ describe('MetricsResolver Integration Tests', () => { }); // Trigger polling by simulating subscription - trackerService.subscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION); // Wait a bit for potential multiple executions await new Promise((resolve) => setTimeout(resolve, 100)); @@ -155,13 +156,13 @@ describe('MetricsResolver Integration Tests', () => { const trackerService = module.get(SubscriptionTrackerService); // Trigger polling by starting subscription - trackerService.subscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION); // Wait for the polling interval to trigger (1000ms for CPU) await new Promise((resolve) => setTimeout(resolve, 1100)); expect(publishSpy).toHaveBeenCalledWith( - PUBSUB_CHANNEL.CPU_UTILIZATION, + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION, expect.objectContaining({ systemMetricsCpu: expect.objectContaining({ id: 'info/cpu-load', @@ -171,7 +172,7 @@ describe('MetricsResolver Integration Tests', () => { }) ); - trackerService.unsubscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); + trackerService.unsubscribe(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION); publishSpy.mockRestore(); }); @@ -180,13 +181,13 @@ describe('MetricsResolver Integration Tests', () => { const trackerService = module.get(SubscriptionTrackerService); // Trigger polling by starting subscription - trackerService.subscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION); // Wait for the polling interval to trigger (2000ms for memory) await new Promise((resolve) => setTimeout(resolve, 2100)); expect(publishSpy).toHaveBeenCalledWith( - PUBSUB_CHANNEL.MEMORY_UTILIZATION, + GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION, expect.objectContaining({ systemMetricsMemory: expect.objectContaining({ id: 'memory-utilization', @@ -197,7 +198,7 @@ describe('MetricsResolver Integration Tests', () => { }) ); - trackerService.unsubscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + trackerService.unsubscribe(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION); publishSpy.mockRestore(); }); @@ -214,7 +215,7 @@ describe('MetricsResolver Integration Tests', () => { vi.spyOn(service, 'generateCpuLoad').mockRejectedValueOnce(new Error('CPU error')); // Trigger polling - trackerService.subscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION); // Wait for polling interval to trigger and handle error (1000ms for CPU) await new Promise((resolve) => setTimeout(resolve, 1100)); @@ -224,7 +225,7 @@ describe('MetricsResolver Integration Tests', () => { expect.any(Error) ); - trackerService.unsubscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); + trackerService.unsubscribe(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION); loggerSpy.mockRestore(); }); @@ -241,7 +242,7 @@ describe('MetricsResolver Integration Tests', () => { vi.spyOn(service, 'generateMemoryLoad').mockRejectedValueOnce(new Error('Memory error')); // Trigger polling - trackerService.subscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION); // Wait for polling interval to trigger and handle error (2000ms for memory) await new Promise((resolve) => setTimeout(resolve, 2100)); @@ -251,7 +252,7 @@ describe('MetricsResolver Integration Tests', () => { expect.any(Error) ); - trackerService.unsubscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + trackerService.unsubscribe(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION); loggerSpy.mockRestore(); }); }); @@ -263,26 +264,30 @@ describe('MetricsResolver Integration Tests', () => { module.get(SubscriptionManagerService); // Start polling - trackerService.subscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); - trackerService.subscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION); // Wait a bit for subscriptions to be fully set up await new Promise((resolve) => setTimeout(resolve, 100)); // Verify subscriptions are active - expect(subscriptionManager.isSubscriptionActive(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(true); - expect(subscriptionManager.isSubscriptionActive(PUBSUB_CHANNEL.MEMORY_UTILIZATION)).toBe( - true - ); + expect( + subscriptionManager.isSubscriptionActive(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION) + ).toBe(true); + expect( + subscriptionManager.isSubscriptionActive(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION) + ).toBe(true); // Clean up the module await module.close(); // Subscriptions should be cleaned up - expect(subscriptionManager.isSubscriptionActive(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(false); - expect(subscriptionManager.isSubscriptionActive(PUBSUB_CHANNEL.MEMORY_UTILIZATION)).toBe( - false - ); + expect( + subscriptionManager.isSubscriptionActive(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION) + ).toBe(false); + expect( + subscriptionManager.isSubscriptionActive(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION) + ).toBe(false); }); }); }); diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts index cbd47e86ba..13c5f793fa 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -2,9 +2,10 @@ import { Logger, OnModuleInit } from '@nestjs/common'; import { Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js'; import { CpuPackages, CpuUtilization } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js'; import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js'; @@ -28,16 +29,16 @@ export class MetricsResolver implements OnModuleInit { onModuleInit() { // Register CPU polling with 1 second interval this.subscriptionTracker.registerTopic( - PUBSUB_CHANNEL.CPU_UTILIZATION, + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION, async () => { const payload = await this.cpuService.generateCpuLoad(); - pubsub.publish(PUBSUB_CHANNEL.CPU_UTILIZATION, { systemMetricsCpu: payload }); + pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION, { systemMetricsCpu: payload }); }, 1000 ); this.subscriptionTracker.registerTopic( - PUBSUB_CHANNEL.CPU_TELEMETRY, + GRAPHQL_PUBSUB_CHANNEL.CPU_TELEMETRY, async () => { const packageList = (await this.cpuTopologyService.generateTelemetry()) ?? []; @@ -59,7 +60,7 @@ export class MetricsResolver implements OnModuleInit { this.logger.debug(`CPU_TELEMETRY payload: ${JSON.stringify(packages)}`); // Publish the payload - pubsub.publish(PUBSUB_CHANNEL.CPU_TELEMETRY, { + pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.CPU_TELEMETRY, { systemMetricsCpuTelemetry: packages, }); @@ -70,10 +71,12 @@ export class MetricsResolver implements OnModuleInit { // Register memory polling with 2 second interval this.subscriptionTracker.registerTopic( - PUBSUB_CHANNEL.MEMORY_UTILIZATION, + GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION, async () => { const payload = await this.memoryService.generateMemoryLoad(); - pubsub.publish(PUBSUB_CHANNEL.MEMORY_UTILIZATION, { systemMetricsMemory: payload }); + pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION, { + systemMetricsMemory: payload, + }); }, 2000 ); @@ -109,7 +112,7 @@ export class MetricsResolver implements OnModuleInit { resource: Resource.INFO, }) public async systemMetricsCpuSubscription() { - return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + return this.subscriptionHelper.createTrackedSubscription(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION); } @Subscription(() => CpuPackages, { @@ -121,7 +124,7 @@ export class MetricsResolver implements OnModuleInit { resource: Resource.INFO, }) public async systemMetricsCpuTelemetrySubscription() { - return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.CPU_TELEMETRY); + return this.subscriptionHelper.createTrackedSubscription(GRAPHQL_PUBSUB_CHANNEL.CPU_TELEMETRY); } @Subscription(() => MemoryUtilization, { @@ -133,6 +136,8 @@ export class MetricsResolver implements OnModuleInit { resource: Resource.INFO, }) public async systemMetricsMemorySubscription() { - return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + return this.subscriptionHelper.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION + ); } } 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..87a1218884 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts @@ -46,7 +46,7 @@ vi.mock('@app/core/pubsub.js', () => ({ pubsub: { publish: vi.fn(), }, - PUBSUB_CHANNEL: { + GRAPHQL_PUBSUB_CHANNEL: { NOTIFICATION_OVERVIEW: 'notification_overview', NOTIFICATION_ADDED: 'notification_added', }, diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts index fe6e56ad6b..de7335f4a1 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts @@ -2,10 +2,11 @@ import { Args, Mutation, Query, ResolveField, Resolver, Subscription } from '@ne import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { AppError } from '@app/core/errors/app-error.js'; -import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { createSubscription } from '@app/core/pubsub.js'; import { Notification, NotificationData, @@ -152,7 +153,7 @@ export class NotificationsResolver { resource: Resource.NOTIFICATIONS, }) async notificationAdded() { - return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_ADDED); + return createSubscription(GRAPHQL_PUBSUB_CHANNEL.NOTIFICATION_ADDED); } @Subscription(() => NotificationOverview) @@ -161,6 +162,6 @@ export class NotificationsResolver { resource: Resource.NOTIFICATIONS, }) async notificationsOverview() { - return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW); + return createSubscription(GRAPHQL_PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW); } } 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 6ec780d666..c2cfdaf99b 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts @@ -3,6 +3,7 @@ import { readdir, readFile, rename, stat, unlink, writeFile } from 'fs/promises' import { basename, join } from 'path'; import type { Stats } from 'fs'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { FSWatcher, watch } from 'chokidar'; import { ValidationError } from 'class-validator'; import { execa } from 'execa'; @@ -12,7 +13,7 @@ import { encode as encodeIni } from 'ini'; import { v7 as uuidv7 } from 'uuid'; import { AppError } from '@app/core/errors/app-error.js'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { NotificationIni } from '@app/core/types/states/notification.js'; import { fileExists } from '@app/core/utils/files/file-exists.js'; import { parseConfig } from '@app/core/utils/misc/parse-config.js'; @@ -118,7 +119,7 @@ export class NotificationsService { if (type === NotificationType.UNREAD) { this.publishOverview(); - pubsub.publish(PUBSUB_CHANNEL.NOTIFICATION_ADDED, { + pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.NOTIFICATION_ADDED, { notificationAdded: notification, }); } @@ -137,7 +138,7 @@ export class NotificationsService { } private publishOverview(overview = NotificationsService.overview) { - return pubsub.publish(PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW, { + return pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW, { notificationsOverview: overview, }); } diff --git a/api/src/unraid-api/graph/resolvers/owner/owner.resolver.ts b/api/src/unraid-api/graph/resolvers/owner/owner.resolver.ts index c4f20ca5d2..1dd550a735 100644 --- a/api/src/unraid-api/graph/resolvers/owner/owner.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/owner/owner.resolver.ts @@ -2,9 +2,10 @@ import { ConfigService } from '@nestjs/config'; import { Query, Resolver, Subscription } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; -import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { createSubscription } from '@app/core/pubsub.js'; import { Owner } from '@app/unraid-api/graph/resolvers/owner/owner.model.js'; // Question: should we move this into the connect plugin, or should this always be available? @@ -39,6 +40,6 @@ export class OwnerResolver { resource: Resource.OWNER, }) public ownerSubscription() { - return createSubscription(PUBSUB_CHANNEL.OWNER); + return createSubscription(GRAPHQL_PUBSUB_CHANNEL.OWNER); } } diff --git a/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts b/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts index 8bcc2e9e3f..980e966c66 100644 --- a/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts @@ -3,9 +3,10 @@ import { ConfigService } from '@nestjs/config'; import { Query, Resolver, Subscription } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; -import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { createSubscription } from '@app/core/pubsub.js'; import { getters } from '@app/store/index.js'; import { MinigraphStatus } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js'; import { @@ -42,7 +43,7 @@ export class ServerResolver { resource: Resource.SERVERS, }) public async serversSubscription() { - return createSubscription(PUBSUB_CHANNEL.SERVERS); + return createSubscription(GRAPHQL_PUBSUB_CHANNEL.SERVERS); } private getLocalServer(): ServerModel { diff --git a/api/src/unraid-api/graph/services/subscription-helper.service.spec.ts b/api/src/unraid-api/graph/services/subscription-helper.service.spec.ts index 42ec4815cd..c6c7d3e2d1 100644 --- a/api/src/unraid-api/graph/services/subscription-helper.service.spec.ts +++ b/api/src/unraid-api/graph/services/subscription-helper.service.spec.ts @@ -1,9 +1,10 @@ import { Logger } from '@nestjs/common'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { PubSub } from 'graphql-subscriptions'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js'; import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; @@ -28,7 +29,9 @@ describe('SubscriptionHelperService', () => { describe('createTrackedSubscription', () => { it('should create an async iterator that tracks subscriptions', async () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); expect(iterator).toBeDefined(); expect(iterator.next).toBeDefined(); @@ -37,29 +40,35 @@ describe('SubscriptionHelperService', () => { expect(iterator[Symbol.asyncIterator]).toBeDefined(); // Should have subscribed - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); }); it('should return itself when Symbol.asyncIterator is called', () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); expect(iterator[Symbol.asyncIterator]()).toBe(iterator); }); it('should unsubscribe when return() is called', async () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); await iterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); }); it('should unsubscribe when throw() is called', async () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); try { await iterator.throw?.(new Error('Test error')); @@ -67,14 +76,14 @@ describe('SubscriptionHelperService', () => { // Expected to throw } - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); }); }); describe('integration with pubsub', () => { it('should receive published messages', async () => { const iterator = helperService.createTrackedSubscription<{ cpuUtilization: any }>( - PUBSUB_CHANNEL.CPU_UTILIZATION + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION ); const testData = { @@ -92,7 +101,7 @@ describe('SubscriptionHelperService', () => { await new Promise((resolve) => setTimeout(resolve, 10)); // Publish a message - await (pubsub as PubSub).publish(PUBSUB_CHANNEL.CPU_UTILIZATION, testData); + await (pubsub as PubSub).publish(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION, testData); // Wait for the message const result = await consumePromise; @@ -107,21 +116,27 @@ describe('SubscriptionHelperService', () => { // Register handlers to verify start/stop behavior const onStart = vi.fn(); const onStop = vi.fn(); - trackerService.registerTopic(PUBSUB_CHANNEL.CPU_UTILIZATION, onStart, onStop); + trackerService.registerTopic(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION, onStart, onStop); // Create first subscriber - const iterator1 = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + const iterator1 = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); expect(onStart).toHaveBeenCalledTimes(1); // Create second subscriber - const iterator2 = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(2); + const iterator2 = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(2); expect(onStart).toHaveBeenCalledTimes(1); // Should not call again // Create third subscriber - const iterator3 = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(3); + const iterator3 = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(3); // Set up consumption promises first const consume1 = iterator1.next(); @@ -133,7 +148,7 @@ describe('SubscriptionHelperService', () => { // Publish a message - all should receive it const testData = { cpuUtilization: { id: 'test', load: 75, cpus: [] } }; - await (pubsub as PubSub).publish(PUBSUB_CHANNEL.CPU_UTILIZATION, testData); + await (pubsub as PubSub).publish(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION, testData); const [result1, result2, result3] = await Promise.all([consume1, consume2, consume3]); @@ -143,17 +158,17 @@ describe('SubscriptionHelperService', () => { // Clean up first subscriber await iterator1.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(2); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(2); expect(onStop).not.toHaveBeenCalled(); // Clean up second subscriber await iterator2.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); expect(onStop).not.toHaveBeenCalled(); // Clean up last subscriber - should trigger onStop await iterator3.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); expect(onStop).toHaveBeenCalledTimes(1); }); @@ -161,18 +176,26 @@ describe('SubscriptionHelperService', () => { const iterations = 10; for (let i = 0; i < iterations; i++) { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe( + 1 + ); await iterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe( + 0 + ); } }); it('should properly clean up on error', async () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); const testError = new Error('Test error'); @@ -183,13 +206,15 @@ describe('SubscriptionHelperService', () => { expect(error).toBe(testError); } - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); }); it('should log debug messages for subscription lifecycle', async () => { vi.clearAllMocks(); - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); expect(loggerSpy).toHaveBeenCalledWith( expect.stringContaining('Subscription added for topic') @@ -205,9 +230,9 @@ describe('SubscriptionHelperService', () => { describe('different topic types', () => { it('should handle INFO channel subscriptions', async () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.INFO); + const iterator = helperService.createTrackedSubscription(GRAPHQL_PUBSUB_CHANNEL.INFO); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.INFO)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.INFO)).toBe(1); // Set up consumption promise first const consumePromise = iterator.next(); @@ -216,47 +241,51 @@ describe('SubscriptionHelperService', () => { await new Promise((resolve) => setTimeout(resolve, 10)); const testData = { info: { id: 'test-info' } }; - await (pubsub as PubSub).publish(PUBSUB_CHANNEL.INFO, testData); + await (pubsub as PubSub).publish(GRAPHQL_PUBSUB_CHANNEL.INFO, testData); const result = await consumePromise; expect(result.value).toEqual(testData); await iterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.INFO)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.INFO)).toBe(0); }); it('should track multiple different topics independently', async () => { - const cpuIterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); - const infoIterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.INFO); + const cpuIterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); + const infoIterator = helperService.createTrackedSubscription(GRAPHQL_PUBSUB_CHANNEL.INFO); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.INFO)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.INFO)).toBe(1); const allCounts = trackerService.getAllSubscriberCounts(); - expect(allCounts.get(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); - expect(allCounts.get(PUBSUB_CHANNEL.INFO)).toBe(1); + expect(allCounts.get(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(allCounts.get(GRAPHQL_PUBSUB_CHANNEL.INFO)).toBe(1); await cpuIterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.INFO)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.INFO)).toBe(1); await infoIterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.INFO)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.INFO)).toBe(0); }); }); describe('edge cases', () => { it('should handle return() called multiple times', async () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); await iterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); // Second return should be idempotent await iterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); // Check that idempotent message was logged expect(loggerSpy).toHaveBeenCalledWith( @@ -265,7 +294,9 @@ describe('SubscriptionHelperService', () => { }); it('should handle async iterator protocol correctly', async () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); // Test that it works in for-await loop (would use Symbol.asyncIterator) const receivedMessages: any[] = []; @@ -285,7 +316,7 @@ describe('SubscriptionHelperService', () => { // Publish messages for (let i = 0; i < maxMessages; i++) { - await (pubsub as PubSub).publish(PUBSUB_CHANNEL.CPU_UTILIZATION, { + await (pubsub as PubSub).publish(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION, { cpuUtilization: { id: `test-${i}`, load: i * 10, cpus: [] }, }); } @@ -300,7 +331,7 @@ describe('SubscriptionHelperService', () => { // Clean up await iterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); }); }); }); diff --git a/api/src/unraid-api/graph/services/subscription-helper.service.ts b/api/src/unraid-api/graph/services/subscription-helper.service.ts index 07adef005d..8ab3d94f28 100644 --- a/api/src/unraid-api/graph/services/subscription-helper.service.ts +++ b/api/src/unraid-api/graph/services/subscription-helper.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; -import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; + +import { createSubscription } from '@app/core/pubsub.js'; import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; /** @@ -21,7 +23,7 @@ import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subsc * \@Subscription(() => MetricsUpdate) * async metricsSubscription() { * // Topic must be registered first via SubscriptionTrackerService - * return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.METRICS); + * return this.subscriptionHelper.createTrackedSubscription(GRAPHQL_PUBSUB_CHANNEL.METRICS); * } */ @Injectable() @@ -33,7 +35,9 @@ export class SubscriptionHelperService { * @param topic The subscription topic/channel to subscribe to * @returns A proxy async iterator with automatic cleanup */ - public createTrackedSubscription(topic: PUBSUB_CHANNEL | string): AsyncIterableIterator { + public createTrackedSubscription( + topic: GRAPHQL_PUBSUB_CHANNEL | string + ): AsyncIterableIterator { const innerIterator = createSubscription(topic); // Subscribe when the subscription starts From 071efeac45917c3f51ac3d9da5e6bdec49bae59c Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 25 Nov 2025 14:29:54 -0500 Subject: [PATCH 15/16] feat: make casbin not always log --- api/src/environment.ts | 1 + api/src/unraid-api/auth/casbin/casbin.service.ts | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/src/environment.ts b/api/src/environment.ts index 94107eab18..da29db922b 100644 --- a/api/src/environment.ts +++ b/api/src/environment.ts @@ -92,6 +92,7 @@ export const LOG_LEVEL = process.env.LOG_LEVEL : process.env.ENVIRONMENT === 'production' ? 'INFO' : 'DEBUG'; +export const LOG_CASBIN = process.env.LOG_CASBIN === 'true'; export const SUPPRESS_LOGS = process.env.SUPPRESS_LOGS === 'true'; export const MOTHERSHIP_GRAPHQL_LINK = process.env.MOTHERSHIP_GRAPHQL_LINK ? process.env.MOTHERSHIP_GRAPHQL_LINK diff --git a/api/src/unraid-api/auth/casbin/casbin.service.ts b/api/src/unraid-api/auth/casbin/casbin.service.ts index 632d0ff8f7..be4441baa9 100644 --- a/api/src/unraid-api/auth/casbin/casbin.service.ts +++ b/api/src/unraid-api/auth/casbin/casbin.service.ts @@ -2,7 +2,7 @@ import { Injectable, InternalServerErrorException, Logger, OnModuleInit } from ' import { Model as CasbinModel, Enforcer, newEnforcer, StringAdapter } from 'casbin'; -import { LOG_LEVEL } from '@app/environment.js'; +import { LOG_CASBIN, LOG_LEVEL } from '@app/environment.js'; @Injectable() export class CasbinService { @@ -20,9 +20,8 @@ export class CasbinService { const casbinPolicy = new StringAdapter(policy); try { const enforcer = await newEnforcer(casbinModel, casbinPolicy); - if (LOG_LEVEL === 'TRACE') { - enforcer.enableLog(true); - } + // Casbin request logging is extremely verbose; keep it off unless explicitly enabled. + enforcer.enableLog(LOG_CASBIN && LOG_LEVEL === 'TRACE'); return enforcer; } catch (error: unknown) { From 9ae3f3cec31c20ba1881294e62902d6e31349a88 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 26 Nov 2025 15:36:13 -0500 Subject: [PATCH 16/16] feat: Enhance UserProfile component to conditionally render banner gradient based on CSS variable - Updated UserProfile component to load banner gradient from a CSS variable, allowing for dynamic styling. - Added tests to verify banner rendering behavior based on the presence of the CSS variable, ensuring correct functionality regardless of theme store settings. - Removed outdated test cases that relied solely on theme store flags for banner gradient rendering. --- web/__test__/components/UserProfile.test.ts | 65 ++++++++++++++----- web/src/components/UserProfile.standalone.vue | 15 ++++- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/web/__test__/components/UserProfile.test.ts b/web/__test__/components/UserProfile.test.ts index ef4d2a53e6..f252d000e9 100644 --- a/web/__test__/components/UserProfile.test.ts +++ b/web/__test__/components/UserProfile.test.ts @@ -358,8 +358,53 @@ describe('UserProfile.standalone.vue', () => { expect(wrapper.find('[data-testid="notifications-sidebar"]').exists()).toBe(true); }); - it('conditionally renders banner based on theme store', async () => { - const bannerSelector = 'div.absolute.z-0'; + it('renders banner gradient when CSS variable is set (even if theme store has banner disabled)', async () => { + const gradientValue = 'linear-gradient(to right, #111111, #222222)'; + document.documentElement.style.setProperty('--banner-gradient', gradientValue); + + const localPinia = createTestingPinia({ + createSpy: vi.fn, + initialState: { + server: { ...initialServerData }, + theme: { + theme: { + name: 'white', + banner: false, + bannerGradient: false, + descriptionShow: true, + textColor: '', + metaColor: '', + bgColor: '', + }, + }, + }, + stubActions: false, + }); + setActivePinia(localPinia); + + const localWrapper = mount(UserProfile, { + props: { + server: JSON.stringify(initialServerData), + }, + global: { + plugins: [localPinia], + stubs, + }, + }); + + await localWrapper.vm.$nextTick(); + + const bannerEl = localWrapper.find('div.absolute.z-0'); + expect(bannerEl.exists()).toBe(true); + expect(bannerEl.attributes('style')).toContain(gradientValue); + + localWrapper.unmount(); + document.documentElement.style.removeProperty('--banner-gradient'); + setActivePinia(pinia); + }); + + it('does not render banner gradient when CSS variable is absent, regardless of theme store flags', async () => { + document.documentElement.style.removeProperty('--banner-gradient'); themeStore.theme = { ...themeStore.theme!, @@ -368,19 +413,7 @@ describe('UserProfile.standalone.vue', () => { }; await wrapper.vm.$nextTick(); - expect(themeStore.bannerGradient).toContain('background-image: linear-gradient'); - expect(wrapper.find(bannerSelector).exists()).toBe(true); - - themeStore.theme!.bannerGradient = false; - await wrapper.vm.$nextTick(); - - expect(themeStore.bannerGradient).toBeUndefined(); - expect(wrapper.find(bannerSelector).exists()).toBe(false); - - themeStore.theme!.bannerGradient = true; - await wrapper.vm.$nextTick(); - - expect(themeStore.bannerGradient).toContain('background-image: linear-gradient'); - expect(wrapper.find(bannerSelector).exists()).toBe(true); + const bannerEl = wrapper.find('div.absolute.z-0'); + expect(bannerEl.exists()).toBe(false); }); }); diff --git a/web/src/components/UserProfile.standalone.vue b/web/src/components/UserProfile.standalone.vue index 609ab906cd..7337c9e71a 100644 --- a/web/src/components/UserProfile.standalone.vue +++ b/web/src/components/UserProfile.standalone.vue @@ -35,7 +35,18 @@ const description = computed(() => serverStore.description); const guid = computed(() => serverStore.guid); const keyfile = computed(() => serverStore.keyfile); const lanIp = computed(() => serverStore.lanIp); -const bannerGradient = computed(() => themeStore.bannerGradient); +const bannerGradient = ref(); + +const loadBannerGradientFromCss = () => { + if (typeof window === 'undefined') return; + + const rawGradient = getComputedStyle(document.documentElement) + .getPropertyValue('--banner-gradient') + .trim(); + + bannerGradient.value = rawGradient ? `background-image: ${rawGradient};` : undefined; +}; + const theme = computed(() => themeStore.theme); // Control dropdown open state @@ -85,6 +96,8 @@ onBeforeMount(() => { }); onMounted(() => { + loadBannerGradientFromCss(); + if (devConfig.VITE_MOCK_USER_SESSION && devConfig.NODE_ENV === 'development') { document.cookie = 'unraid_session_cookie=mockusersession'; }