diff --git a/api/ecosystem.config.json b/api/ecosystem.config.json deleted file mode 100644 index 4fea24e6e..000000000 --- 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 0dfe521f9..f0fbb669d 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 0928c60b9..b13c1ef31 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 000000000..6a22df6b5 --- /dev/null +++ b/api/nodemon.json @@ -0,0 +1,17 @@ +{ + "watch": [ + "dist/main.js" + ], + "ignore": [ + "node_modules", + "src", + ".env.*" + ], + "exec": "node $UNRAID_API_SERVER_ENTRYPOINT", + "signal": "SIGTERM", + "ext": "js,json", + "restartable": "rs", + "env": { + "NODE_ENV": "production" + } +} diff --git a/api/package.json b/api/package.json index 26e51095b..b40e6ec19 100644 --- a/api/package.json +++ b/api/package.json @@ -129,6 +129,7 @@ "nestjs-pino": "4.4.0", "node-cache": "5.1.2", "node-window-polyfill": "1.0.4", + "nodemon": "3.1.10", "openid-client": "6.6.4", "p-retry": "7.0.0", "passport-custom": "1.1.1", @@ -137,7 +138,7 @@ "pino": "9.9.0", "pino-http": "10.5.0", "pino-pretty": "13.1.1", - "pm2": "6.0.8", + "proper-lockfile": "^4.1.2", "reflect-metadata": "^0.1.14", "rxjs": "7.8.2", "semver": "7.7.2", @@ -188,6 +189,7 @@ "@types/mustache": "4.2.6", "@types/node": "22.18.0", "@types/pify": "6.1.0", + "@types/proper-lockfile": "^4.1.4", "@types/semver": "7.7.0", "@types/sendmail": "1.4.7", "@types/stoppable": "1.1.3", @@ -203,7 +205,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 924b3f4ca..6c6bb98ac 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 85ace81c0..000000000 --- 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 6c05e817c..000000000 --- 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 000000000..124641e68 --- /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/__test__/environment.nodemon-paths.test.ts b/api/src/__test__/environment.nodemon-paths.test.ts new file mode 100644 index 000000000..3e5ac9a46 --- /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/__test__/graphql/resolvers/rclone-api.service.test.ts b/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts index e4adb7452..1ac456037 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/cli.ts b/api/src/cli.ts index e12776216..d1d692861 100644 --- a/api/src/cli.ts +++ b/api/src/cli.ts @@ -1,12 +1,25 @@ import '@app/dotenv.js'; import { Logger } from '@nestjs/common'; +import { appendFileSync } from 'node:fs'; import { CommandFactory } from 'nest-commander'; import { LOG_LEVEL, SUPPRESS_LOGS } from '@app/environment.js'; import { LogService } from '@app/unraid-api/cli/log.service.js'; +const BOOT_LOG_PATH = '/var/log/unraid-api/boot.log'; + +const logToBootFile = (message: string): void => { + const timestamp = new Date().toISOString(); + const line = `[${timestamp}] [cli] ${message}\n`; + try { + appendFileSync(BOOT_LOG_PATH, line); + } catch { + // Silently fail if we can't write to boot log + } +}; + const getUnraidApiLocation = async () => { const { execa } = await import('execa'); try { @@ -26,6 +39,8 @@ const getLogger = () => { const logger = getLogger(); try { + logToBootFile(`CLI started with args: ${process.argv.slice(2).join(' ')}`); + await import('json-bigint-patch'); const { CliModule } = await import('@app/unraid-api/cli/cli.module.js'); @@ -38,10 +53,17 @@ try { nativeShell: { executablePath: await getUnraidApiLocation() }, }, }); + logToBootFile('CLI completed successfully'); process.exit(0); } catch (error) { + // Always log errors to boot file for boot-time debugging + const errorMessage = error instanceof Error ? error.stack || error.message : String(error); + logToBootFile(`CLI ERROR: ${errorMessage}`); + if (logger) { logger.error('ERROR:', error); + } else { + console.error('ERROR:', error); } process.exit(1); } diff --git a/api/src/core/log.ts b/api/src/core/log.ts index 84f66601f..c9a112aa9 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,8 +16,10 @@ 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 + 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 ? nullDestination @@ -25,7 +27,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/pubsub.ts b/api/src/core/pubsub.ts index e3b679b86..280614e5a 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/core/utils/pm2/unraid-api-running.ts b/api/src/core/utils/pm2/unraid-api-running.ts deleted file mode 100644 index 4e65aa3ac..000000000 --- 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 000000000..c4ee4d7e6 --- /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 => { + 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; + } + + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; diff --git a/api/src/environment.ts b/api/src/environment.ts index b1d3c2bad..be2c63cfb 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 = @@ -91,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 @@ -98,12 +100,18 @@ 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 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'); +export const NODEMON_PID_PATH = process.env.NODEMON_PID_PATH ?? '/var/run/unraid-api/nodemon.pid'; +export const NODEMON_LOCK_PATH = process.env.NODEMON_LOCK_PATH ?? '/var/run/unraid-api/nodemon.lock'; +export const UNRAID_API_CWD = process.env.UNRAID_API_CWD ?? UNRAID_API_ROOT; +export const UNRAID_API_SERVER_ENTRYPOINT = join(UNRAID_API_CWD, 'dist', 'main.js'); export const PATHS_CONFIG_MODULES = process.env.PATHS_CONFIG_MODULES ?? '/boot/config/plugins/dynamix.my.servers/configs'; diff --git a/api/src/store/listeners/array-event-listener.ts b/api/src/store/listeners/array-event-listener.ts index 6291a0919..70da63e80 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/auth/casbin/casbin.service.ts b/api/src/unraid-api/auth/casbin/casbin.service.ts index 632d0ff8f..be4441baa 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) { 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 bbedcfbaf..ffdbd4d77 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 7f248390d..a92c12694 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 7befdcb0e..9569475cb 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 97e116fcb..2c991a943 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 c15d8e25a..0e5d7085f 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.integration.spec.ts b/api/src/unraid-api/cli/nodemon.service.integration.spec.ts new file mode 100644 index 000000000..bb649f2b2 --- /dev/null +++ b/api/src/unraid-api/cli/nodemon.service.integration.spec.ts @@ -0,0 +1,142 @@ +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'; + +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(), + always: vi.fn(), +} as unknown as LogService; + +describe('NodemonService (real nodemon)', () => { + const tmpRoot = join(tmpdir(), 'nodemon-service-'); + let workdir: string; + let scriptPath: string; + let configPath: string; + let appLogPath: string; + let nodemonLogPath: 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'); + appLogPath = join(workdir, 'app.log'); + nodemonLogPath = join(workdir, 'nodemon.log'); + pidPath = join(workdir, 'nodemon.pid'); + + await writeFile( + scriptPath, + [ + "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( + 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_LOCK_PATH: join(workdir, 'nodemon.lock'), + NODEMON_PATH: nodemonPath, + NODEMON_PID_PATH: pidPath, + PATHS_LOGS_DIR: workdir, + PATHS_LOGS_FILE: appLogPath, + PATHS_NODEMON_LOG_FILE: nodemonLogPath, + UNRAID_API_CWD: workdir, + UNRAID_API_SERVER_ENTRYPOINT: join(workdir, 'app.js'), + })); + + 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 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); + 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 new file mode 100644 index 000000000..fdf52632b --- /dev/null +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -0,0 +1,569 @@ +import { spawn } from 'node:child_process'; +import { createWriteStream, openSync } from 'node:fs'; +import * as fs from 'node:fs/promises'; + +import { execa } from 'execa'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { fileExists, fileExistsSync } 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(), + write: 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; + }; +}; + +const createSpawnMock = (pid?: number) => { + const unref = vi.fn(); + return { + pid, + unref, + } as unknown as ReturnType; +}; + +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), +})); +vi.mock('node:fs', () => ({ + createWriteStream: vi.fn(), + openSync: vi.fn().mockReturnValue(42), + writeSync: vi.fn(), +})); +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + mkdir: vi.fn(), + writeFile: vi.fn(), + rm: vi.fn(), + readFile: vi.fn(), + appendFile: vi.fn(), + }; +}); +vi.mock('execa', () => ({ execa: vi.fn() })); +vi.mock('proper-lockfile', () => ({ + lock: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(undefined)), +})); +vi.mock('@app/core/utils/files/file-exists.js', () => ({ + fileExists: vi.fn().mockResolvedValue(false), + fileExistsSync: vi.fn().mockReturnValue(true), +})); +vi.mock('@app/environment.js', () => ({ + LOG_LEVEL: 'INFO', + SUPPRESS_LOGS: false, + NODEMON_CONFIG_PATH: '/etc/unraid-api/nodemon.json', + NODEMON_LOCK_PATH: '/var/run/unraid-api/nodemon.lock', + 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', + PATHS_NODEMON_LOG_FILE: '/var/log/unraid-api/nodemon.log', + UNRAID_API_CWD: '/usr/local/unraid-api', + UNRAID_API_SERVER_ENTRYPOINT: '/usr/local/unraid-api/dist/main.js', +})); + +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); + 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' + ); + 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(); + vi.mocked(createWriteStream).mockImplementation(() => createLogStreamMock()); + vi.mocked(openSync).mockReturnValue(42); + vi.mocked(spawn).mockReturnValue(createSpawnMock(123)); + mockMkdir.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined as unknown as void); + mockRm.mockResolvedValue(undefined as unknown as void); + vi.mocked(fileExists).mockResolvedValue(false); + vi.mocked(fileExistsSync).mockReturnValue(true); + killSpy.mockReturnValue(true); + findMatchingSpy.mockResolvedValue([]); + findDirectMainSpy.mockResolvedValue([]); + terminateSpy.mockResolvedValue(); + stopPm2Spy.mockResolvedValue(); + }); + + 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/log', { recursive: true }); + 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 spawnMock = createSpawnMock(123); + vi.mocked(spawn).mockReturnValue(spawnMock); + killSpy.mockReturnValue(true); + findMatchingSpy.mockResolvedValue([]); + + await service.start({ env: { LOG_LEVEL: 'DEBUG' } }); + + expect(stopPm2Spy).toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledWith( + process.execPath, + ['/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', 42, 42], + } + ); + expect(openSync).toHaveBeenCalledWith('/var/log/unraid-api/nodemon.log', 'a'); + expect(spawnMock.unref).toHaveBeenCalled(); + expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '123'); + expect(logger.info).toHaveBeenCalledWith('Started nodemon (pid 123)'); + }); + + 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(spawn).not.toHaveBeenCalled(); + }); + + it('throws error when spawn fails', async () => { + const service = new NodemonService(logger); + const error = new Error('Command not found'); + vi.mocked(spawn).mockImplementation(() => { + throw error; + }); + + await expect(service.start()).rejects.toThrow('Failed to start nodemon: Command not found'); + expect(mockWriteFile).not.toHaveBeenCalledWith( + '/var/run/unraid-api/nodemon.pid', + expect.anything() + ); + expect(logger.info).not.toHaveBeenCalled(); + }); + + it('throws a clear error when the log file cannot be opened', async () => { + const service = new NodemonService(logger); + const openError = new Error('EACCES: permission denied'); + vi.mocked(openSync).mockImplementation(() => { + throw openError; + }); + + await expect(service.start()).rejects.toThrow( + 'Failed to start nodemon: EACCES: permission denied' + ); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('throws error when pid is missing', async () => { + const service = new NodemonService(logger); + const spawnMock = createSpawnMock(undefined); + vi.mocked(spawn).mockReturnValue(spawnMock); + + await expect(service.start()).rejects.toThrow( + 'Failed to start nodemon: process spawned but no PID was assigned' + ); + expect(mockWriteFile).not.toHaveBeenCalledWith( + '/var/run/unraid-api/nodemon.pid', + expect.anything() + ); + expect(logger.info).not.toHaveBeenCalled(); + }); + + it('throws when nodemon exits immediately after start', async () => { + const service = new NodemonService(logger); + const spawnMock = createSpawnMock(456); + vi.mocked(spawn).mockReturnValue(spawnMock); + 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(mockRm).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', { force: true }); + expect(logsSpy).toHaveBeenCalledWith(50); + }); + + 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' + ).mockResolvedValue(999); + vi.spyOn( + service as unknown as { isPidRunning: (pid: number) => Promise }, + 'isPidRunning' + ).mockResolvedValue(true); + + const spawnMock = createSpawnMock(456); + vi.mocked(spawn).mockReturnValue(spawnMock); + + await service.start(); + + expect(stopSpy).toHaveBeenCalledWith({ quiet: true }); + expect(mockRm).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', { force: true }); + expect(spawn).toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith( + 'unraid-api already running under nodemon (pid 999); restarting for a fresh start.' + ); + }); + + it('removes stale pid file and starts when recorded pid is dead', async () => { + const service = new NodemonService(logger); + const spawnMock = createSpawnMock(111); + vi.mocked(spawn).mockReturnValue(spawnMock); + 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(spawn).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('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 spawnMock = createSpawnMock(222); + vi.mocked(spawn).mockReturnValue(spawnMock); + + await service.start(); + + expect(terminateSpy).toHaveBeenCalledWith([888]); + expect(spawn).toHaveBeenCalled(); + }); + + it('terminates direct main.js processes before starting nodemon', async () => { + const service = new NodemonService(logger); + findMatchingSpy.mockResolvedValue([]); + findDirectMainSpy.mockResolvedValue([321, 654]); + + const spawnMock = createSpawnMock(777); + vi.mocked(spawn).mockReturnValue(spawnMock); + + await service.start(); + + expect(terminateSpy).toHaveBeenCalledWith([321, 654]); + expect(spawn).toHaveBeenCalledWith( + process.execPath, + ['/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 and no orphans', async () => { + const service = new NodemonService(logger); + vi.mocked(fileExists).mockResolvedValue(false); + findMatchingSpy.mockResolvedValue([]); + findDirectMainSpy.mockResolvedValue([]); + + const result = await service.status(); + + expect(result).toBe(false); + expect(logger.info).toHaveBeenCalledWith('unraid-api is not running (no pid file).'); + }); + + it('returns running and warns when orphan processes found without pid file', async () => { + const service = new NodemonService(logger); + vi.mocked(fileExists).mockResolvedValue(false); + findMatchingSpy.mockResolvedValue([]); + findDirectMainSpy.mockResolvedValue([123, 456]); + + const result = await service.status(); + + expect(result).toBe(true); + expect(logger.warn).toHaveBeenCalledWith( + 'No PID file, but found orphaned processes: nodemon=none, main.js=123,456' + ); + }); + + it('returns running and warns when orphan nodemon found without pid file', async () => { + const service = new NodemonService(logger); + vi.mocked(fileExists).mockResolvedValue(false); + findMatchingSpy.mockResolvedValue([789]); + findDirectMainSpy.mockResolvedValue([]); + + const result = await service.status(); + + expect(result).toBe(true); + expect(logger.warn).toHaveBeenCalledWith( + 'No PID file, but found orphaned processes: nodemon=789, main.js=none' + ); + }); + + it('stop: sends SIGTERM to nodemon and waits for exit', async () => { + const service = new NodemonService(logger); + vi.mocked(fileExists).mockResolvedValue(true); + vi.mocked(fs.readFile).mockResolvedValue('100'); + findDirectMainSpy.mockResolvedValue([200]); + const waitForPidsToExitSpy = vi + .spyOn( + service as unknown as { + waitForPidsToExit: (pids: number[], timeoutMs?: number) => Promise; + }, + 'waitForPidsToExit' + ) + .mockResolvedValue([]); + + await service.stop(); + + expect(killSpy).toHaveBeenCalledWith(100, 'SIGTERM'); + expect(waitForPidsToExitSpy).toHaveBeenCalledWith([100, 200], 5000); + expect(mockRm).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', { force: true }); + }); + + it('stop: force kills remaining processes after timeout', async () => { + const service = new NodemonService(logger); + vi.mocked(fileExists).mockResolvedValue(true); + vi.mocked(fs.readFile).mockResolvedValue('100'); + findDirectMainSpy.mockResolvedValue([200]); + vi.spyOn( + service as unknown as { + waitForPidsToExit: (pids: number[], timeoutMs?: number) => Promise; + }, + 'waitForPidsToExit' + ).mockResolvedValue([100, 200]); + const terminatePidsWithForceSpy = vi + .spyOn( + service as unknown as { + terminatePidsWithForce: (pids: number[], gracePeriodMs?: number) => Promise; + }, + 'terminatePidsWithForce' + ) + .mockResolvedValue(); + + await service.stop(); + + expect(logger.warn).toHaveBeenCalledWith('Force killing remaining processes: 100, 200'); + expect(terminatePidsWithForceSpy).toHaveBeenCalledWith([100, 200]); + }); + + it('stop: cleans up orphaned main.js when no pid file exists', async () => { + const service = new NodemonService(logger); + vi.mocked(fileExists).mockResolvedValue(false); + findDirectMainSpy.mockResolvedValue([300, 400]); + const terminatePidsWithForceSpy = vi + .spyOn( + service as unknown as { + terminatePidsWithForce: (pids: number[], gracePeriodMs?: number) => Promise; + }, + 'terminatePidsWithForce' + ) + .mockResolvedValue(); + + await service.stop(); + + expect(logger.warn).toHaveBeenCalledWith('No nodemon pid file found.'); + expect(logger.warn).toHaveBeenCalledWith( + 'Found orphaned main.js processes: 300, 400. Terminating.' + ); + expect(terminatePidsWithForceSpy).toHaveBeenCalledWith([300, 400]); + }); + + it('stop --force: skips graceful wait', async () => { + const service = new NodemonService(logger); + vi.mocked(fileExists).mockResolvedValue(true); + vi.mocked(fs.readFile).mockResolvedValue('100'); + findDirectMainSpy.mockResolvedValue([]); + const waitForPidsToExitSpy = vi + .spyOn( + service as unknown as { + waitForPidsToExit: (pids: number[], timeoutMs?: number) => Promise; + }, + 'waitForPidsToExit' + ) + .mockResolvedValue([100]); + vi.spyOn( + service as unknown as { + terminatePidsWithForce: (pids: number[], gracePeriodMs?: number) => Promise; + }, + 'terminatePidsWithForce' + ).mockResolvedValue(); + + await service.stop({ force: true }); + + expect(waitForPidsToExitSpy).toHaveBeenCalledWith([100], 0); + }); + + 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(''); + }); + + 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 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 spawnMock = createSpawnMock(456); + vi.mocked(spawn).mockReturnValue(spawnMock); + + await service.restart({ env: { LOG_LEVEL: 'DEBUG' } }); + + expect(stopSpy).toHaveBeenCalledWith({ quiet: true }); + expect(waitSpy).toHaveBeenCalled(); + expect(spawn).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 new file mode 100644 index 000000000..a01f82fa5 --- /dev/null +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -0,0 +1,534 @@ +import { Injectable } from '@nestjs/common'; +import { spawn } from 'node:child_process'; +import { openSync, writeSync } from 'node:fs'; +import { appendFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { execa } from 'execa'; +import { lock } from 'proper-lockfile'; + +import { fileExists, fileExistsSync } from '@app/core/utils/files/file-exists.js'; +import { + NODEMON_CONFIG_PATH, + NODEMON_LOCK_PATH, + NODEMON_PATH, + NODEMON_PID_PATH, + PATHS_LOGS_DIR, + PATHS_LOGS_FILE, + PATHS_NODEMON_LOG_FILE, + UNRAID_API_CWD, + UNRAID_API_SERVER_ENTRYPOINT, +} from '@app/environment.js'; +import { LogService } from '@app/unraid-api/cli/log.service.js'; + +const LOCK_TIMEOUT_SECONDS = 30; + +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; +}; + +const BOOT_LOG_PATH = '/var/log/unraid-api/boot.log'; + +@Injectable() +export class NodemonService { + constructor(private readonly logger: LogService) {} + + private async logToBootFile(message: string): Promise { + const timestamp = new Date().toISOString(); + const line = `[${timestamp}] [nodemon-service] ${message}\n`; + try { + await appendFile(BOOT_LOG_PATH, line); + } catch { + // Fallback to console if file write fails (e.g., directory doesn't exist yet) + } + } + + private validatePaths(): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + if (!fileExistsSync(NODEMON_PATH)) { + errors.push(`NODEMON_PATH does not exist: ${NODEMON_PATH}`); + } + if (!fileExistsSync(NODEMON_CONFIG_PATH)) { + errors.push(`NODEMON_CONFIG_PATH does not exist: ${NODEMON_CONFIG_PATH}`); + } + if (!fileExistsSync(UNRAID_API_CWD)) { + errors.push(`UNRAID_API_CWD does not exist: ${UNRAID_API_CWD}`); + } + + return { valid: errors.length === 0, errors }; + } + + 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 }); + await mkdir(dirname(NODEMON_LOCK_PATH), { recursive: true }); + await writeFile(NODEMON_LOCK_PATH, '', { flag: 'a' }); + } + + private async withLock(fn: () => Promise): Promise { + let release: (() => Promise) | null = null; + try { + release = await lock(NODEMON_LOCK_PATH, { + stale: LOCK_TIMEOUT_SECONDS * 1000, + retries: { + retries: Math.floor(LOCK_TIMEOUT_SECONDS * 10), + factor: 1, + minTimeout: 100, + maxTimeout: 100, + }, + }); + return await fn(); + } finally { + if (release) { + await release().catch(() => {}); + } + } + } + + 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) { + 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 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 + } + try { + await rm('/var/log/.pm2', { recursive: true, force: true }); + } catch { + // Ignore errors when removing pm2 state - shouldn't block API startup + } + } + + 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; + } + } + + 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 []; + } + } + + private async findDirectMainPids(): 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(UNRAID_API_SERVER_ENTRYPOINT)) + .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}` + ); + } + } + } + + 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.'); + } + + /** + * Wait for processes to exit, returns array of PIDs that didn't exit in time + */ + private async waitForPidsToExit(pids: number[], timeoutMs = 5000): Promise { + if (timeoutMs <= 0) return pids.filter((pid) => pid > 0); + + const deadline = Date.now() + timeoutMs; + const remaining = new Set(pids.filter((pid) => pid > 0)); + + while (remaining.size > 0 && Date.now() < deadline) { + for (const pid of remaining) { + if (!(await this.isPidRunning(pid))) { + remaining.delete(pid); + } + } + if (remaining.size > 0) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + return [...remaining]; + } + + /** + * Terminate PIDs with SIGTERM, then SIGKILL after timeout + */ + private async terminatePidsWithForce(pids: number[], gracePeriodMs = 2000): Promise { + // Send SIGTERM to all + for (const pid of pids) { + try { + process.kill(pid, 'SIGTERM'); + } catch { + // Process may have already exited + } + } + + // Wait for graceful exit + const remaining = await this.waitForPidsToExit(pids, gracePeriodMs); + + // Force kill any that didn't exit + for (const pid of remaining) { + try { + process.kill(pid, 'SIGKILL'); + this.logger.debug?.(`Sent SIGKILL to pid ${pid}`); + } catch { + // Process may have already exited + } + } + + // Brief wait for SIGKILL to take effect + if (remaining.length > 0) { + await this.waitForPidsToExit(remaining, 1000); + } + } + + async start(options: StartOptions = {}) { + // Log boot attempt with diagnostic info + await this.logToBootFile('=== Starting unraid-api via nodemon ==='); + await this.logToBootFile(`NODEMON_PATH: ${NODEMON_PATH}`); + await this.logToBootFile(`NODEMON_CONFIG_PATH: ${NODEMON_CONFIG_PATH}`); + await this.logToBootFile(`UNRAID_API_CWD: ${UNRAID_API_CWD}`); + await this.logToBootFile(`NODEMON_PID_PATH: ${NODEMON_PID_PATH}`); + await this.logToBootFile(`process.cwd(): ${process.cwd()}`); + await this.logToBootFile(`process.execPath: ${process.execPath}`); + await this.logToBootFile(`PATH: ${process.env.PATH}`); + + // Validate paths before proceeding + const { valid, errors } = this.validatePaths(); + if (!valid) { + for (const error of errors) { + await this.logToBootFile(`ERROR: ${error}`); + this.logger.error(error); + } + throw new Error(`Path validation failed: ${errors.join('; ')}`); + } + await this.logToBootFile('Path validation passed'); + + try { + await this.ensureNodemonDependencies(); + await this.logToBootFile('Dependencies ensured'); + } catch (error) { + const msg = `Failed to ensure nodemon dependencies: ${error instanceof Error ? error.message : error}`; + await this.logToBootFile(`ERROR: ${msg}`); + this.logger.error(msg); + throw error; + } + + await this.withLock(() => this.startInternal(options)); + } + + private async startInternal(options: StartOptions = {}) { + await this.stopPm2IfRunning(); + await this.logToBootFile('PM2 cleanup complete'); + + const existingPid = await this.getStoredPid(); + if (existingPid) { + const running = await this.isPidRunning(existingPid); + if (running) { + await this.logToBootFile(`Found running nodemon (pid ${existingPid}), restarting`); + this.logger.info( + `unraid-api already running under nodemon (pid ${existingPid}); restarting for a fresh start.` + ); + await this.stop({ quiet: true }); + await this.waitForNodemonExit(); + await rm(NODEMON_PID_PATH, { force: true }); + } else { + await this.logToBootFile(`Found stale pid file (${existingPid}), cleaning up`); + 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 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) { + await this.logToBootFile(`Found orphan nodemon processes: ${liveDiscoveredPids.join(', ')}`); + this.logger.info( + `Found nodemon process(es) (${liveDiscoveredPids.join(', ')}) without a pid file; restarting for a fresh start.` + ); + await this.terminatePids(liveDiscoveredPids); + await this.waitForNodemonExit(); + } + + const directMainPids = await this.findDirectMainPids(); + if (directMainPids.length > 0) { + await this.logToBootFile(`Found direct main.js processes: ${directMainPids.join(', ')}`); + 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) + ); + const env = { + ...process.env, + // Ensure PATH includes standard locations for boot-time reliability + PATH: `/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}`, + NODE_ENV: 'production', + PATHS_LOGS_FILE, + PATHS_NODEMON_LOG_FILE, + NODEMON_CONFIG_PATH, + NODEMON_PID_PATH, + UNRAID_API_CWD, + UNRAID_API_SERVER_ENTRYPOINT, + ...overrides, + } as Record; + + await this.logToBootFile( + `Spawning: ${process.execPath} ${NODEMON_PATH} --config ${NODEMON_CONFIG_PATH}` + ); + + let logFd: number | null = null; + try { + // Use file descriptor for stdio - more reliable for detached processes at boot + logFd = openSync(PATHS_NODEMON_LOG_FILE, 'a'); + + // Write initial message to nodemon log + writeSync(logFd, 'Starting nodemon...\n'); + + // Use native spawn instead of execa for more reliable detached process handling + const nodemonProcess = spawn( + process.execPath, // Use current node executable path + [NODEMON_PATH, '--config', NODEMON_CONFIG_PATH, '--quiet'], + { + cwd: UNRAID_API_CWD, + env, + detached: true, + stdio: ['ignore', logFd, logFd], + } + ); + + nodemonProcess.unref(); + + if (!nodemonProcess.pid) { + await this.logToBootFile('ERROR: Failed to spawn nodemon - no PID assigned'); + throw new Error('Failed to start nodemon: process spawned but no PID was assigned'); + } + + await writeFile(NODEMON_PID_PATH, `${nodemonProcess.pid}`); + await this.logToBootFile(`Spawned nodemon with PID: ${nodemonProcess.pid}`); + + // Multiple verification checks with increasing delays for boot-time reliability + const verificationDelays = [200, 500, 1000]; + for (const delay of verificationDelays) { + await new Promise((resolve) => setTimeout(resolve, delay)); + const stillRunning = await this.isPidRunning(nodemonProcess.pid); + if (!stillRunning) { + const recentLogs = await this.logs(50); + await rm(NODEMON_PID_PATH, { force: true }); + const logMessage = recentLogs ? ` Recent logs:\n${recentLogs}` : ''; + await this.logToBootFile(`ERROR: Nodemon exited after ${delay}ms`); + await this.logToBootFile(`Recent logs: ${recentLogs}`); + throw new Error(`Nodemon exited immediately after start.${logMessage}`); + } + await this.logToBootFile(`Verification passed after ${delay}ms`); + } + + await this.logToBootFile(`Successfully started nodemon (pid ${nodemonProcess.pid})`); + this.logger.info(`Started nodemon (pid ${nodemonProcess.pid})`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + await this.logToBootFile(`ERROR: ${errorMessage}`); + throw new Error(`Failed to start nodemon: ${errorMessage}`); + } + } + + async stop(options: StopOptions = {}) { + const nodemonPid = await this.getStoredPid(); + + // Find child processes BEFORE sending any signals + const childPids = await this.findDirectMainPids(); + + if (!nodemonPid) { + if (!options.quiet) { + this.logger.warn('No nodemon pid file found.'); + } + // Clean up orphaned children if any exist + if (childPids.length > 0) { + this.logger.warn( + `Found orphaned main.js processes: ${childPids.join(', ')}. Terminating.` + ); + await this.terminatePidsWithForce(childPids); + } + return; + } + + // Step 1: SIGTERM to nodemon (will forward to child) + try { + process.kill(nodemonPid, 'SIGTERM'); + this.logger.trace(`Sent SIGTERM to nodemon (pid ${nodemonPid})`); + } catch (error) { + // Process may have already exited + this.logger.debug?.(`nodemon (pid ${nodemonPid}) already gone: ${error}`); + } + + // Step 2: Wait for both nodemon and children to exit + const allPids = [nodemonPid, ...childPids]; + const gracefulTimeout = options.force ? 0 : 5000; + const remainingPids = await this.waitForPidsToExit(allPids, gracefulTimeout); + + // Step 3: Force kill any remaining processes + if (remainingPids.length > 0) { + this.logger.warn(`Force killing remaining processes: ${remainingPids.join(', ')}`); + await this.terminatePidsWithForce(remainingPids); + } + + // Step 4: Clean up PID file + await rm(NODEMON_PID_PATH, { force: true }); + } + + async restart(options: StartOptions = {}) { + // Delegate to start so both commands share identical logic + await this.start(options); + } + + async status(): Promise { + const pid = await this.getStoredPid(); + + // Check for orphaned processes even without PID file + const orphanNodemonPids = await this.findMatchingNodemonPids(); + const orphanMainPids = await this.findDirectMainPids(); + + if (!pid) { + if (orphanNodemonPids.length > 0 || orphanMainPids.length > 0) { + this.logger.warn( + `No PID file, but found orphaned processes: nodemon=${orphanNodemonPids.join(',') || 'none'}, main.js=${orphanMainPids.join(',') || 'none'}` + ); + return true; // Processes ARE running, just not tracked + } + 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): 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 ''; + } + } +} 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 8c16cd518..000000000 --- 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 b16a4a40b..000000000 --- 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 1e03dea5c..49188cf77 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 66d54a513..166162fa6 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 64c7d890d..61660612c 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 6e1b6b6e2..489198e3b 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 995dd0743..376c89c6e 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/array/array.resolver.ts b/api/src/unraid-api/graph/resolvers/array/array.resolver.ts index 40734973e..45ad31932 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 07b304c3c..8ed56ab90 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 884805059..483a87623 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 558c2b4be..6f1e73276 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 933100f1b..ab8823e08 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 8e34166b6..0be2febfc 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 ba7e974f2..39843d2a2 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 5b244773f..54bc9c88d 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/info/versions/versions.model.ts b/api/src/unraid-api/graph/resolvers/info/versions/versions.model.ts index dd6fe5d88..2080cbbb9 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 a711a17dd..836122b3b 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/graph/resolvers/metrics/metrics.resolver.integration.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts index 12b899a09..0c7fe074a 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 cbd47e86b..13c5f793f 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 1c582ddd3..87a121888 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 fe6e56ad6..de7335f4a 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 6ec780d66..c2cfdaf99 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 c4f20ca5d..1dd550a73 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 8bcc2e9e3..980e966c6 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 42ec4815c..c6c7d3e2d 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 07adef005..8ab3d94f2 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 diff --git a/api/src/unraid-api/main.ts b/api/src/unraid-api/main.ts index 4b753abfa..cc07b5b63 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/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid-api b/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid-api index 0d14d33a9..d653bab3c 100755 --- a/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid-api +++ b/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid-api @@ -19,25 +19,47 @@ uninstall() { true } +# Boot log location for debugging startup issues +boot_log="/var/log/unraid-api/boot.log" + +# Helper to log boot messages with timestamp +log_boot() { + echo "[$(date -Iseconds)] [rc.unraid-api] $1" >> "$boot_log" 2>/dev/null || true +} + # Service control functions start() { echo "Starting Unraid API service..." + # Ensure PATH includes standard locations for boot-time reliability + export PATH="/usr/local/bin:/usr/bin:/bin:$PATH" + + # Create log directory if it doesn't exist (must be done before logging) + mkdir -p /var/log/unraid-api + + log_boot "start() called" + log_boot "PATH: $PATH" + log_boot "api_base_dir: $api_base_dir" + log_boot "unraid_binary_path: $unraid_binary_path" + # Restore vendored API plugins if they were installed if [ -x "$scripts_dir/dependencies.sh" ]; then - "$scripts_dir/dependencies.sh" restore || { + log_boot "Running dependencies.sh restore" + if "$scripts_dir/dependencies.sh" restore; then + log_boot "dependencies.sh restore succeeded" + else + log_boot "dependencies.sh restore FAILED" echo "Failed to restore API plugin dependencies! Continuing with start, but API plugins may be unavailable." - } + fi else + log_boot "Warning: dependencies.sh not found at $scripts_dir/dependencies.sh" echo "Warning: dependencies.sh script not found or not executable" fi - # Create log directory if it doesn't exist - mkdir -p /var/log/unraid-api - # Copy env file if needed if [ -f "${api_base_dir}/.env.production" ] && [ ! -f "${api_base_dir}/.env" ]; then cp "${api_base_dir}/.env.production" "${api_base_dir}/.env" + log_boot "Copied .env.production to .env" fi # Start the flash backup service if available and connect plugin is enabled @@ -51,9 +73,16 @@ start() { # Start the API service if [ -x "${unraid_binary_path}" ]; then - "${unraid_binary_path}" start - return $? + log_boot "Calling ${unraid_binary_path} start" + # Capture output and return code for boot debugging + output=$("${unraid_binary_path}" start 2>&1) + result=$? + echo "$output" + log_boot "unraid-api start output: $output" + log_boot "unraid-api start exit code: $result" + return $result else + log_boot "ERROR: Binary not found or not executable at ${unraid_binary_path}" echo "Error: Unraid API binary not found or not executable at ${unraid_binary_path}" return 1 fi diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8db98eb3a..7742208b4 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,9 @@ importers: pino-pretty: specifier: 13.1.1 version: 13.1.1 - pm2: - specifier: 6.0.8 - version: 6.0.8 + proper-lockfile: + specifier: ^4.1.2 + version: 4.1.2 reflect-metadata: specifier: ^0.1.14 version: 0.1.14 @@ -413,6 +416,9 @@ importers: '@types/pify': specifier: 6.1.0 version: 6.1.0 + '@types/proper-lockfile': + specifier: ^4.1.4 + version: 4.1.4 '@types/semver': specifier: 7.7.0 version: 7.7.0 @@ -458,9 +464,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 +4031,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 +4753,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==} @@ -4932,6 +4918,9 @@ packages: resolution: {integrity: sha512-HCVIdzNiVAi7OxWTAZagTBNzylgNhImtx442pMcu8QZHzDHElS3ccgqaYIuHskpaeG7rIbYlN5XP5tcOAf8F2w==} deprecated: This is a stub types definition. pify provides its own type definitions, so you do not need this installed. + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + '@types/qs@6.9.18': resolution: {integrity: sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==} @@ -4944,6 +4933,9 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/retry@0.12.5': + resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} + '@types/semver@7.7.0': resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==} @@ -5771,16 +5763,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 +5806,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 +5913,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 +5941,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 +6026,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 +6049,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 +6232,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 +6286,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 +6391,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 +6579,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 +6670,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 +6678,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 +6703,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 +6752,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 +6835,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 +7071,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 +7323,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 +7513,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 +7582,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 +7605,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 +7658,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 +7900,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 +8338,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 +8698,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 +8711,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 +9289,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 +9303,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 +9382,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 +9422,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 +9769,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 +9948,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 +10013,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 +10330,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 +10354,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 +10470,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 +10591,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 +10716,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 +10870,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 +10940,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 +11001,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 +11475,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 +11489,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 +12175,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 +12199,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.8: + resolution: {integrity: sha512-oaowlmEM6BaYY+8o+9D9cuzxpWQWHqHTMKakMxXu0E+UCIOMTljyIPO15jcnaCwJtZu/zWDotK7mOIHvWD9mcw==} vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} @@ -16053,56 +15818,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 +16215,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.8 '@swc/core-darwin-arm64@1.13.5': optional: true @@ -16728,8 +16443,6 @@ snapshots: '@tokenizer/token@0.3.0': {} - '@tootallnate/quickjs-emscripten@0.23.0': {} - '@ts-morph/common@0.25.0': dependencies: minimatch: 9.0.5 @@ -16922,6 +16635,10 @@ snapshots: dependencies: pify: 3.0.0 + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.5 + '@types/qs@6.9.18': {} '@types/range-parser@1.2.7': {} @@ -16932,6 +16649,8 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/retry@0.12.5': {} + '@types/semver@7.7.0': {} '@types/send@0.17.4': @@ -17885,14 +17604,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 +17634,6 @@ snapshots: ansi_up@6.0.6: {} - ansis@4.0.0-node10: {} - ansis@4.1.0: {} anymatch@3.1.3: @@ -18065,10 +17774,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 +17801,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 +17839,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 +17904,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 +17928,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 +18184,6 @@ snapshots: chardet@2.1.0: {} - charm@0.1.2: {} - check-error@2.1.1: {} chokidar@3.6.0: @@ -18556,10 +18249,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 +18338,6 @@ snapshots: commander@14.0.0: {} - commander@2.15.1: {} - commander@2.20.3: {} commander@5.1.0: {} @@ -18844,8 +18531,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 +18654,6 @@ snapshots: csv-parse@5.6.0: {} - culvert@0.1.2: {} - d@1.0.2: dependencies: es5-ext: 0.10.64 @@ -18978,8 +18661,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 +18690,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 +18706,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 +18789,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 +18994,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 +19334,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 +19565,6 @@ snapshots: event-target-shim@5.0.1: {} - eventemitter2@5.0.1: {} - eventemitter2@6.4.9: {} eventemitter3@3.1.2: {} @@ -20029,12 +19682,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 +19704,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 +19795,6 @@ snapshots: transitivePeerDependencies: - encoding - fclone@1.0.11: {} - fd-package-json@2.0.0: dependencies: walk-up-path: 4.0.0 @@ -20254,9 +19897,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 +20057,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 +20066,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 +20379,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 +20559,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 +20877,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 +20887,6 @@ snapshots: dependencies: argparse: 2.0.1 - jsbn@1.1.0: {} - jsdom@26.1.0: dependencies: cssstyle: 4.5.0 @@ -21815,8 +21428,6 @@ snapshots: mkdirp-classic@0.5.3: {} - mkdirp@1.0.4: {} - mkdirp@3.0.1: {} mlly@1.7.4: @@ -21835,8 +21446,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 +21505,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 +21550,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 +22179,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 +22327,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 +22412,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 +22661,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 +22702,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 +22842,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 +23001,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 +23148,6 @@ snapshots: dependencies: queue-microtask: 1.2.3 - run-series@1.1.9: {} - rxjs@6.6.7: dependencies: tslib: 1.14.1 @@ -23924,8 +23390,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 +23472,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 +23479,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 +23527,6 @@ snapshots: sprintf-js@1.0.3: {} - sprintf-js@1.1.2: {} - sprintf-js@1.1.3: {} ssh2@1.16.0: @@ -24570,8 +24017,6 @@ snapshots: tslib@1.14.1: {} - tslib@1.9.3: {} - tslib@2.4.1: {} tslib@2.6.3: {} @@ -24585,17 +24030,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 +24749,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 +24770,7 @@ snapshots: vue-component-type-helpers@3.0.6: {} - vue-component-type-helpers@3.1.3: {} + vue-component-type-helpers@3.1.8: {} vue-demi@0.14.10(vue@3.5.20(typescript@5.9.2)): dependencies: diff --git a/web/__test__/components/UserProfile.test.ts b/web/__test__/components/UserProfile.test.ts index ef4d2a53e..f252d000e 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/composables/gql/graphql.ts b/web/composables/gql/graphql.ts index a6171b677..833521150 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/components/UserProfile.standalone.vue b/web/src/components/UserProfile.standalone.vue index 609ab906c..7337c9e71 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'; } diff --git a/web/src/composables/gql/graphql.ts b/web/src/composables/gql/graphql.ts index e683aa0c0..a44fe13e7 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 c682b1e2f..0ea4a91cf 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";