diff --git a/CLAUDE.md b/CLAUDE.md index 640f920e65..c2a4b81f66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,9 @@ This is the Unraid API monorepo containing multiple packages that provide API fu ## Essential Commands +pnpm does not use `--` to pass additional arguments. +For example, to target a specific test file, `pnpm test ` is sufficient. + ### Development ```bash diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index acaf5daa92..e09b0f3f55 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -1,5 +1,5 @@ { - "version": "4.25.3", + "version": "4.27.2", "extraOrigins": [], "sandbox": true, "ssoSubIds": [], diff --git a/api/ecosystem.config.json b/api/ecosystem.config.json deleted file mode 100644 index 4fea24e6ef..0000000000 --- a/api/ecosystem.config.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/pm2-ecosystem", - "apps": [ - { - "name": "unraid-api", - "script": "./dist/main.js", - "cwd": "/usr/local/unraid-api", - "exec_mode": "fork", - "wait_ready": true, - "listen_timeout": 15000, - "max_restarts": 10, - "min_uptime": 10000, - "watch": false, - "interpreter": "/usr/local/bin/node", - "ignore_watch": ["node_modules", "src", ".env.*", "myservers.cfg"], - "out_file": "/var/log/graphql-api.log", - "error_file": "/var/log/graphql-api.log", - "merge_logs": true, - "kill_timeout": 10000 - } - ] -} diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 0dfe521f9e..f0fbb669df 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1673,8 +1673,8 @@ type PackageVersions { """npm version""" npm: String - """pm2 version""" - pm2: String + """nodemon version""" + nodemon: String """Git version""" git: String diff --git a/api/legacy/generated-schema-legacy.graphql b/api/legacy/generated-schema-legacy.graphql index 0928c60b90..b13c1ef314 100644 --- a/api/legacy/generated-schema-legacy.graphql +++ b/api/legacy/generated-schema-legacy.graphql @@ -1257,7 +1257,7 @@ type Versions { openssl: String perl: String php: String - pm2: String + nodemon: String postfix: String postgresql: String python: String diff --git a/api/nodemon.json b/api/nodemon.json new file mode 100644 index 0000000000..6a22df6b5b --- /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 26e51095bf..b40e6ec197 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 924b3f4ca3..6c6bb98ac5 100755 --- a/api/scripts/build.ts +++ b/api/scripts/build.ts @@ -7,7 +7,7 @@ import { exit } from 'process'; import type { PackageJson } from 'type-fest'; import { $, cd } from 'zx'; -import { getDeploymentVersion } from './get-deployment-version.js'; +import { getDeploymentVersion } from '@app/../scripts/get-deployment-version.js'; type ApiPackageJson = PackageJson & { version: string; @@ -94,7 +94,7 @@ try { await writeFile('./deploy/pack/package.json', JSON.stringify(parsedPackageJson, null, 4)); // Copy necessary files to the pack directory - await $`cp -r dist README.md .env.* ecosystem.config.json ./deploy/pack/`; + await $`cp -r dist README.md .env.* nodemon.json ./deploy/pack/`; // Change to the pack directory and install dependencies cd('./deploy/pack'); diff --git a/api/src/__test__/core/utils/pm2/dummy-process.js b/api/src/__test__/core/utils/pm2/dummy-process.js deleted file mode 100644 index 85ace81c08..0000000000 --- a/api/src/__test__/core/utils/pm2/dummy-process.js +++ /dev/null @@ -1,5 +0,0 @@ -/* eslint-disable no-undef */ -// Dummy process for PM2 testing -setInterval(() => { - // Keep process alive -}, 1000); \ No newline at end of file diff --git a/api/src/__test__/core/utils/pm2/unraid-api-running.integration.test.ts b/api/src/__test__/core/utils/pm2/unraid-api-running.integration.test.ts deleted file mode 100644 index 6c05e817c6..0000000000 --- a/api/src/__test__/core/utils/pm2/unraid-api-running.integration.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { existsSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { execa } from 'execa'; -import pm2 from 'pm2'; -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; - -import { isUnraidApiRunning } from '@app/core/utils/pm2/unraid-api-running.js'; - -const __dirname = fileURLToPath(new URL('.', import.meta.url)); -const PROJECT_ROOT = join(__dirname, '../../../../..'); -const DUMMY_PROCESS_PATH = join(__dirname, 'dummy-process.js'); -const CLI_PATH = join(PROJECT_ROOT, 'dist/cli.js'); -const TEST_PROCESS_NAME = 'test-unraid-api'; - -// Shared PM2 connection state -let pm2Connected = false; - -// Helper to ensure PM2 connection is established -async function ensurePM2Connection() { - if (pm2Connected) return; - - return new Promise((resolve, reject) => { - pm2.connect((err) => { - if (err) { - reject(err); - return; - } - pm2Connected = true; - resolve(); - }); - }); -} - -// Helper to delete specific test processes (lightweight, reuses connection) -async function deleteTestProcesses() { - if (!pm2Connected) { - // No connection, nothing to clean up - return; - } - - const deletePromise = new Promise((resolve) => { - // Delete specific processes we might have created - const processNames = ['unraid-api', TEST_PROCESS_NAME]; - let deletedCount = 0; - - const deleteNext = () => { - if (deletedCount >= processNames.length) { - resolve(); - return; - } - - const processName = processNames[deletedCount]; - pm2.delete(processName, () => { - // Ignore errors, process might not exist - deletedCount++; - deleteNext(); - }); - }; - - deleteNext(); - }); - - const timeoutPromise = new Promise((resolve) => { - setTimeout(() => resolve(), 3000); // 3 second timeout - }); - - return Promise.race([deletePromise, timeoutPromise]); -} - -// Helper to ensure PM2 is completely clean (heavy cleanup with daemon kill) -async function cleanupAllPM2Processes() { - // First delete test processes if we have a connection - if (pm2Connected) { - await deleteTestProcesses(); - } - - return new Promise((resolve) => { - // Always connect fresh for daemon kill (in case we weren't connected) - pm2.connect((err) => { - if (err) { - // If we can't connect, assume PM2 is not running - pm2Connected = false; - resolve(); - return; - } - - // Kill the daemon to ensure fresh state - pm2.killDaemon(() => { - pm2.disconnect(); - pm2Connected = false; - // Small delay to let PM2 fully shutdown - setTimeout(resolve, 500); - }); - }); - }); -} - -describe.skipIf(!!process.env.CI)('PM2 integration tests', () => { - beforeAll(async () => { - // Set PM2_HOME to use home directory for testing (not /var/log) - process.env.PM2_HOME = join(homedir(), '.pm2'); - - // Build the CLI if it doesn't exist (only for CLI tests) - if (!existsSync(CLI_PATH)) { - console.log('Building CLI for integration tests...'); - try { - await execa('pnpm', ['build'], { - cwd: PROJECT_ROOT, - stdio: 'inherit', - timeout: 120000, // 2 minute timeout for build - }); - } catch (error) { - console.error('Failed to build CLI:', error); - throw new Error( - 'Cannot run CLI integration tests without built CLI. Run `pnpm build` first.' - ); - } - } - - // Only do a full cleanup once at the beginning - await cleanupAllPM2Processes(); - }, 150000); // 2.5 minute timeout for setup - - afterAll(async () => { - // Only do a full cleanup once at the end - await cleanupAllPM2Processes(); - }); - - afterEach(async () => { - // Lightweight cleanup after each test - just delete our test processes - await deleteTestProcesses(); - }, 5000); // 5 second timeout for cleanup - - describe('isUnraidApiRunning function', () => { - it('should return false when PM2 is not running the unraid-api process', async () => { - const result = await isUnraidApiRunning(); - expect(result).toBe(false); - }); - - it('should return true when PM2 has unraid-api process running', async () => { - // Ensure PM2 connection - await ensurePM2Connection(); - - // Start a dummy process with the name 'unraid-api' - await new Promise((resolve, reject) => { - pm2.start( - { - script: DUMMY_PROCESS_PATH, - name: 'unraid-api', - }, - (startErr) => { - if (startErr) return reject(startErr); - resolve(); - } - ); - }); - - // Give PM2 time to start the process - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const result = await isUnraidApiRunning(); - expect(result).toBe(true); - }, 30000); - - it('should return false when unraid-api process is stopped', async () => { - // Ensure PM2 connection - await ensurePM2Connection(); - - // Start and then stop the process - await new Promise((resolve, reject) => { - pm2.start( - { - script: DUMMY_PROCESS_PATH, - name: 'unraid-api', - }, - (startErr) => { - if (startErr) return reject(startErr); - - // Stop the process after starting - setTimeout(() => { - pm2.stop('unraid-api', (stopErr) => { - if (stopErr) return reject(stopErr); - resolve(); - }); - }, 1000); - } - ); - }); - - await new Promise((resolve) => setTimeout(resolve, 1000)); - - const result = await isUnraidApiRunning(); - expect(result).toBe(false); - }, 30000); - - it('should handle PM2 connection errors gracefully', async () => { - // Disconnect PM2 first to ensure we're testing fresh connection - await new Promise((resolve) => { - pm2.disconnect(); - pm2Connected = false; - setTimeout(resolve, 100); - }); - - // Set an invalid PM2_HOME to force connection failure - const originalPM2Home = process.env.PM2_HOME; - process.env.PM2_HOME = '/invalid/path/that/does/not/exist'; - - const result = await isUnraidApiRunning(); - expect(result).toBe(false); - - // Restore original PM2_HOME - if (originalPM2Home) { - process.env.PM2_HOME = originalPM2Home; - } else { - delete process.env.PM2_HOME; - } - }, 15000); // 15 second timeout to allow for the Promise.race timeout - }); -}); diff --git a/api/src/__test__/core/utils/process/unraid-api-running.integration.test.ts b/api/src/__test__/core/utils/process/unraid-api-running.integration.test.ts new file mode 100644 index 0000000000..124641e68d --- /dev/null +++ b/api/src/__test__/core/utils/process/unraid-api-running.integration.test.ts @@ -0,0 +1,54 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +describe('isUnraidApiRunning (nodemon pid detection)', () => { + let tempDir: string; + let pidPath: string; + + beforeAll(() => { + tempDir = mkdtempSync(join(tmpdir(), 'unraid-api-')); + pidPath = join(tempDir, 'nodemon.pid'); + }); + + afterAll(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + afterEach(() => { + vi.resetModules(); + }); + + async function loadIsRunning() { + vi.doMock('@app/environment.js', async () => { + const actual = + await vi.importActual('@app/environment.js'); + return { ...actual, NODEMON_PID_PATH: pidPath }; + }); + + const module = await import('@app/core/utils/process/unraid-api-running.js'); + return module.isUnraidApiRunning; + } + + it('returns false when pid file is missing', async () => { + const isUnraidApiRunning = await loadIsRunning(); + + expect(await isUnraidApiRunning()).toBe(false); + }); + + it('returns true when a live pid is recorded', async () => { + writeFileSync(pidPath, `${process.pid}`); + const isUnraidApiRunning = await loadIsRunning(); + + expect(await isUnraidApiRunning()).toBe(true); + }); + + it('returns false when pid file is invalid', async () => { + writeFileSync(pidPath, 'not-a-number'); + const isUnraidApiRunning = await loadIsRunning(); + + expect(await isUnraidApiRunning()).toBe(false); + }); +}); diff --git a/api/src/__test__/environment.nodemon-paths.test.ts b/api/src/__test__/environment.nodemon-paths.test.ts new file mode 100644 index 0000000000..3e5ac9a468 --- /dev/null +++ b/api/src/__test__/environment.nodemon-paths.test.ts @@ -0,0 +1,29 @@ +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('nodemon path configuration', () => { + const originalUnraidApiCwd = process.env.UNRAID_API_CWD; + + beforeEach(() => { + vi.resetModules(); + delete process.env.UNRAID_API_CWD; + }); + + afterEach(() => { + if (originalUnraidApiCwd === undefined) { + delete process.env.UNRAID_API_CWD; + } else { + process.env.UNRAID_API_CWD = originalUnraidApiCwd; + } + }); + + it('anchors nodemon paths to the package root by default', async () => { + const environment = await import('@app/environment.js'); + const { UNRAID_API_ROOT, NODEMON_CONFIG_PATH, NODEMON_PATH, UNRAID_API_CWD } = environment; + + expect(UNRAID_API_CWD).toBe(UNRAID_API_ROOT); + expect(NODEMON_CONFIG_PATH).toBe(join(UNRAID_API_ROOT, 'nodemon.json')); + expect(NODEMON_PATH).toBe(join(UNRAID_API_ROOT, 'node_modules', 'nodemon', 'bin', 'nodemon.js')); + }); +}); diff --git a/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts b/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts index e4adb7452b..1ac4560378 100644 --- a/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts +++ b/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts @@ -51,6 +51,8 @@ vi.mock('@app/store/index.js', () => ({ })); vi.mock('@app/environment.js', () => ({ ENVIRONMENT: 'development', + SUPPRESS_LOGS: false, + LOG_LEVEL: 'INFO', environment: { IS_MAIN_PROCESS: true, }, diff --git a/api/src/cli.ts b/api/src/cli.ts index e12776216b..d1d6928611 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 84f66601fa..c9a112aa96 100644 --- a/api/src/core/log.ts +++ b/api/src/core/log.ts @@ -1,7 +1,7 @@ import pino from 'pino'; import pretty from 'pino-pretty'; -import { API_VERSION, LOG_LEVEL, LOG_TYPE, SUPPRESS_LOGS } from '@app/environment.js'; +import { API_VERSION, LOG_LEVEL, LOG_TYPE, PATHS_LOGS_FILE, SUPPRESS_LOGS } from '@app/environment.js'; export const levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const; @@ -16,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 e3b679b86c..280614e5a4 100644 --- a/api/src/core/pubsub.ts +++ b/api/src/core/pubsub.ts @@ -7,8 +7,6 @@ import { PubSub } from 'graphql-subscriptions'; const eventEmitter = new EventEmitter(); eventEmitter.setMaxListeners(30); -export { GRAPHQL_PUBSUB_CHANNEL as PUBSUB_CHANNEL }; - export const pubsub = new PubSub({ eventEmitter }); /** diff --git a/api/src/core/utils/pm2/unraid-api-running.ts b/api/src/core/utils/pm2/unraid-api-running.ts deleted file mode 100644 index 4e65aa3ac9..0000000000 --- a/api/src/core/utils/pm2/unraid-api-running.ts +++ /dev/null @@ -1,40 +0,0 @@ -export const isUnraidApiRunning = async (): Promise => { - const { PM2_HOME } = await import('@app/environment.js'); - - // Set PM2_HOME if not already set - if (!process.env.PM2_HOME) { - process.env.PM2_HOME = PM2_HOME; - } - - const pm2Module = await import('pm2'); - const pm2 = pm2Module.default || pm2Module; - - const pm2Promise = new Promise((resolve) => { - pm2.connect(function (err) { - if (err) { - // Don't reject here, resolve with false since we can't connect to PM2 - resolve(false); - return; - } - - // Now try to describe unraid-api specifically - pm2.describe('unraid-api', function (err, processDescription) { - if (err || processDescription.length === 0) { - // Service not found or error occurred - resolve(false); - } else { - const isOnline = processDescription?.[0]?.pm2_env?.status === 'online'; - resolve(isOnline); - } - - pm2.disconnect(); - }); - }); - }); - - const timeoutPromise = new Promise((resolve) => { - setTimeout(() => resolve(false), 10000); // 10 second timeout - }); - - return Promise.race([pm2Promise, timeoutPromise]); -}; diff --git a/api/src/core/utils/process/unraid-api-running.ts b/api/src/core/utils/process/unraid-api-running.ts new file mode 100644 index 0000000000..c4ee4d7e67 --- /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 b1d3c2bad3..be2c63cfb9 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 6291a09195..70da63e80b 100644 --- a/api/src/store/listeners/array-event-listener.ts +++ b/api/src/store/listeners/array-event-listener.ts @@ -1,9 +1,10 @@ import { isAnyOf } from '@reduxjs/toolkit'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { isEqual } from 'lodash-es'; import { logger } from '@app/core/log.js'; import { getArrayData } from '@app/core/modules/array/get-array-data.js'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { startAppListening } from '@app/store/listeners/listener-middleware.js'; import { loadSingleStateFile } from '@app/store/modules/emhttp.js'; import { StateFileKey } from '@app/store/types.js'; @@ -20,14 +21,14 @@ export const enableArrayEventListener = () => await delay(5_000); const array = getArrayData(getState); if (!isEqual(oldArrayData, array)) { - pubsub.publish(PUBSUB_CHANNEL.ARRAY, { array }); + pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.ARRAY, { array }); logger.debug({ event: array }, 'Array was updated, publishing event'); } subscribe(); } else if (action.meta.arg === StateFileKey.var) { if (!isEqual(getOriginalState().emhttp.var?.name, getState().emhttp.var?.name)) { - await pubsub.publish(PUBSUB_CHANNEL.INFO, { + await pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.INFO, { info: { os: { hostname: getState().emhttp.var?.name, diff --git a/api/src/unraid-api/auth/casbin/casbin.service.ts b/api/src/unraid-api/auth/casbin/casbin.service.ts index 632d0ff8f7..be4441baa9 100644 --- a/api/src/unraid-api/auth/casbin/casbin.service.ts +++ b/api/src/unraid-api/auth/casbin/casbin.service.ts @@ -2,7 +2,7 @@ import { Injectable, InternalServerErrorException, Logger, OnModuleInit } from ' import { Model as CasbinModel, Enforcer, newEnforcer, StringAdapter } from 'casbin'; -import { LOG_LEVEL } from '@app/environment.js'; +import { LOG_CASBIN, LOG_LEVEL } from '@app/environment.js'; @Injectable() export class CasbinService { @@ -20,9 +20,8 @@ export class CasbinService { const casbinPolicy = new StringAdapter(policy); try { const enforcer = await newEnforcer(casbinModel, casbinPolicy); - if (LOG_LEVEL === 'TRACE') { - enforcer.enableLog(true); - } + // Casbin request logging is extremely verbose; keep it off unless explicitly enabled. + enforcer.enableLog(LOG_CASBIN && LOG_LEVEL === 'TRACE'); return enforcer; } catch (error: unknown) { diff --git a/api/src/unraid-api/cli/__test__/report.command.test.ts b/api/src/unraid-api/cli/__test__/report.command.test.ts index bbedcfbaf2..ffdbd4d77a 100644 --- a/api/src/unraid-api/cli/__test__/report.command.test.ts +++ b/api/src/unraid-api/cli/__test__/report.command.test.ts @@ -26,10 +26,10 @@ const mockApiReportService = { generateReport: vi.fn(), }; -// Mock PM2 check +// Mock process manager check const mockIsUnraidApiRunning = vi.fn().mockResolvedValue(true); -vi.mock('@app/core/utils/pm2/unraid-api-running.js', () => ({ +vi.mock('@app/core/utils/process/unraid-api-running.js', () => ({ isUnraidApiRunning: () => mockIsUnraidApiRunning(), })); @@ -50,7 +50,7 @@ describe('ReportCommand', () => { // Clear mocks vi.clearAllMocks(); - // Reset PM2 mock to default + // Reset nodemon mock to default mockIsUnraidApiRunning.mockResolvedValue(true); }); @@ -150,7 +150,7 @@ describe('ReportCommand', () => { // Reset mocks vi.clearAllMocks(); - // Test with API running but PM2 check returns true + // Test with API running but status check returns true mockIsUnraidApiRunning.mockResolvedValue(true); await reportCommand.report(); expect(mockApiReportService.generateReport).toHaveBeenCalledWith(true); diff --git a/api/src/unraid-api/cli/cli-services.module.ts b/api/src/unraid-api/cli/cli-services.module.ts index 7f248390d0..a92c126944 100644 --- a/api/src/unraid-api/cli/cli-services.module.ts +++ b/api/src/unraid-api/cli/cli-services.module.ts @@ -4,7 +4,7 @@ import { DependencyService } from '@app/unraid-api/app/dependency.service.js'; import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js'; import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js'; import { LogService } from '@app/unraid-api/cli/log.service.js'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; +import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js'; import { LegacyConfigModule } from '@app/unraid-api/config/legacy-config.module.js'; import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js'; @@ -21,7 +21,7 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u PluginCliModule.register(), UnraidFileModifierModule, ], - providers: [LogService, PM2Service, ApiKeyService, DependencyService, ApiReportService], + providers: [LogService, NodemonService, ApiKeyService, DependencyService, ApiReportService], exports: [ApiReportService, LogService, ApiKeyService], }) export class CliServicesModule {} diff --git a/api/src/unraid-api/cli/cli.module.ts b/api/src/unraid-api/cli/cli.module.ts index 7befdcb0e4..9569475cb2 100644 --- a/api/src/unraid-api/cli/cli.module.ts +++ b/api/src/unraid-api/cli/cli.module.ts @@ -13,6 +13,7 @@ import { DeveloperCommand } from '@app/unraid-api/cli/developer/developer.comman import { DeveloperQuestions } from '@app/unraid-api/cli/developer/developer.questions.js'; import { LogService } from '@app/unraid-api/cli/log.service.js'; import { LogsCommand } from '@app/unraid-api/cli/logs.command.js'; +import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; import { InstallPluginCommand, ListPluginCommand, @@ -20,7 +21,6 @@ import { RemovePluginCommand, } from '@app/unraid-api/cli/plugins/plugin.command.js'; import { RemovePluginQuestionSet } from '@app/unraid-api/cli/plugins/remove-plugin.questions.js'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; import { ReportCommand } from '@app/unraid-api/cli/report.command.js'; import { RestartCommand } from '@app/unraid-api/cli/restart.command.js'; import { SSOCommand } from '@app/unraid-api/cli/sso/sso.command.js'; @@ -64,7 +64,7 @@ const DEFAULT_PROVIDERS = [ DeveloperQuestions, DeveloperToolsService, LogService, - PM2Service, + NodemonService, ApiKeyService, DependencyService, ApiReportService, diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index 97e116fcbb..2c991a9431 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -559,6 +559,17 @@ export type CpuLoad = { percentUser: Scalars['Float']['output']; }; +export type CpuPackages = Node & { + __typename?: 'CpuPackages'; + id: Scalars['PrefixedID']['output']; + /** Power draw per package (W) */ + power: Array; + /** Temperature per package (°C) */ + temp: Array; + /** Total CPU package power draw (W) */ + totalPower: Scalars['Float']['output']; +}; + export type CpuUtilization = Node & { __typename?: 'CpuUtilization'; /** CPU load for each core */ @@ -869,6 +880,7 @@ export type InfoCpu = Node & { manufacturer?: Maybe; /** CPU model */ model?: Maybe; + packages: CpuPackages; /** Number of physical processors */ processors?: Maybe; /** CPU revision */ @@ -885,6 +897,8 @@ export type InfoCpu = Node & { stepping?: Maybe; /** Number of CPU threads */ threads?: Maybe; + /** Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]] */ + topology: Array>>; /** CPU vendor */ vendor?: Maybe; /** CPU voltage */ @@ -1531,14 +1545,14 @@ export type PackageVersions = { nginx?: Maybe; /** Node.js version */ node?: Maybe; + /** nodemon version */ + nodemon?: Maybe; /** npm version */ npm?: Maybe; /** OpenSSL version */ openssl?: Maybe; /** PHP version */ php?: Maybe; - /** pm2 version */ - pm2?: Maybe; }; export type ParityCheck = { @@ -2053,6 +2067,7 @@ export type Subscription = { parityHistorySubscription: ParityCheck; serversSubscription: Server; systemMetricsCpu: CpuUtilization; + systemMetricsCpuTelemetry: CpuPackages; systemMetricsMemory: MemoryUtilization; upsUpdates: UpsDevice; }; diff --git a/api/src/unraid-api/cli/logs.command.ts b/api/src/unraid-api/cli/logs.command.ts index c15d8e25aa..0e5d7085fe 100644 --- a/api/src/unraid-api/cli/logs.command.ts +++ b/api/src/unraid-api/cli/logs.command.ts @@ -1,6 +1,6 @@ import { Command, CommandRunner, Option } from 'nest-commander'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; +import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; interface LogsOptions { lines: number; @@ -8,7 +8,7 @@ interface LogsOptions { @Command({ name: 'logs', description: 'View logs' }) export class LogsCommand extends CommandRunner { - constructor(private readonly pm2: PM2Service) { + constructor(private readonly nodemon: NodemonService) { super(); } @@ -20,13 +20,6 @@ export class LogsCommand extends CommandRunner { async run(_: string[], options?: LogsOptions): Promise { const lines = options?.lines ?? 100; - await this.pm2.run( - { tag: 'PM2 Logs', stdio: 'inherit' }, - 'logs', - 'unraid-api', - '--lines', - lines.toString(), - '--raw' - ); + await this.nodemon.logs(lines); } } diff --git a/api/src/unraid-api/cli/nodemon.service.integration.spec.ts b/api/src/unraid-api/cli/nodemon.service.integration.spec.ts new file mode 100644 index 0000000000..bb649f2b2f --- /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 0000000000..fdf52632b0 --- /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 0000000000..a01f82fa5c --- /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 8c16cd5188..0000000000 --- a/api/src/unraid-api/cli/pm2.service.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import * as fs from 'node:fs/promises'; - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { LogService } from '@app/unraid-api/cli/log.service.js'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; - -vi.mock('node:fs/promises'); -vi.mock('execa'); -vi.mock('@app/core/utils/files/file-exists.js', () => ({ - fileExists: vi.fn().mockResolvedValue(false), -})); -vi.mock('@app/environment.js', () => ({ - PATHS_LOGS_DIR: '/var/log/unraid-api', - PM2_HOME: '/var/log/.pm2', - PM2_PATH: '/path/to/pm2', - ECOSYSTEM_PATH: '/path/to/ecosystem.config.json', - SUPPRESS_LOGS: false, - LOG_LEVEL: 'info', -})); - -describe('PM2Service', () => { - let pm2Service: PM2Service; - let logService: LogService; - const mockMkdir = vi.mocked(fs.mkdir); - - beforeEach(() => { - vi.clearAllMocks(); - logService = { - trace: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - log: vi.fn(), - info: vi.fn(), - debug: vi.fn(), - } as unknown as LogService; - pm2Service = new PM2Service(logService); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('ensurePm2Dependencies', () => { - it('should create logs directory and log that PM2 will handle its own directory', async () => { - mockMkdir.mockResolvedValue(undefined); - - await pm2Service.ensurePm2Dependencies(); - - expect(mockMkdir).toHaveBeenCalledWith('/var/log/unraid-api', { recursive: true }); - expect(mockMkdir).toHaveBeenCalledTimes(1); // Only logs directory, not PM2_HOME - expect(logService.trace).toHaveBeenCalledWith( - 'PM2_HOME will be created at /var/log/.pm2 when PM2 daemon starts' - ); - }); - - it('should log error but not throw when logs directory creation fails', async () => { - mockMkdir.mockRejectedValue(new Error('Disk full')); - - await expect(pm2Service.ensurePm2Dependencies()).resolves.not.toThrow(); - - expect(logService.error).toHaveBeenCalledWith( - expect.stringContaining('Failed to fully ensure PM2 dependencies: Disk full') - ); - }); - - it('should handle mkdir with recursive flag for nested logs path', async () => { - mockMkdir.mockResolvedValue(undefined); - - await pm2Service.ensurePm2Dependencies(); - - expect(mockMkdir).toHaveBeenCalledWith('/var/log/unraid-api', { recursive: true }); - expect(mockMkdir).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/api/src/unraid-api/cli/pm2.service.ts b/api/src/unraid-api/cli/pm2.service.ts deleted file mode 100644 index b16a4a40b1..0000000000 --- a/api/src/unraid-api/cli/pm2.service.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { mkdir, rm } from 'node:fs/promises'; -import { join } from 'node:path'; - -import type { Options, Result, ResultPromise } from 'execa'; -import { execa, ExecaError } from 'execa'; - -import { fileExists } from '@app/core/utils/files/file-exists.js'; -import { PATHS_LOGS_DIR, PM2_HOME, PM2_PATH } from '@app/environment.js'; -import { LogService } from '@app/unraid-api/cli/log.service.js'; - -type CmdContext = Options & { - /** A tag for logging & debugging purposes. Should represent the operation being performed. */ - tag: string; - /** Default: false. - * - * When true, results will not be automatically handled and logged. - * The caller must handle desired effects, such as logging, error handling, etc. - */ - raw?: boolean; -}; - -@Injectable() -export class PM2Service { - constructor(private readonly logger: LogService) {} - - // Type Overload: if raw is true, return an execa ResultPromise (which is a Promise with extra properties) - /** - * Executes a PM2 command with the specified context and arguments. - * Handles logging automatically (stdout -> trace, stderr -> error), unless the `raw` flag is - * set to true, in which case the caller must handle desired effects. - * - * @param context - Execa Options for command execution, such as a unique tag for logging - * and whether the result should be handled raw. - * @param args - The arguments to pass to the PM2 command. - * @returns ResultPromise\<@param context\> When raw is true - * @returns Promise\ When raw is false - */ - run(context: T & { raw: true }, ...args: string[]): ResultPromise; - - run(context: CmdContext & { raw?: false }, ...args: string[]): Promise; - - async run(context: CmdContext, ...args: string[]) { - const { tag, raw, ...execOptions } = context; - // Default to true to match execa's default behavior - execOptions.extendEnv ??= true; - execOptions.shell ??= 'bash'; - - // Ensure /usr/local/bin is in PATH for Node.js - const currentPath = execOptions.env?.PATH || process.env.PATH || '/usr/bin:/bin:/usr/sbin:/sbin'; - const needsPathUpdate = !currentPath.includes('/usr/local/bin'); - const finalPath = needsPathUpdate ? `/usr/local/bin:${currentPath}` : currentPath; - - // Always ensure PM2_HOME is set in the environment for every PM2 command - execOptions.env = { - ...execOptions.env, - PM2_HOME, - ...(needsPathUpdate && { PATH: finalPath }), - }; - - const pm2Args = args.some((arg) => arg === '--no-color') ? args : ['--no-color', ...args]; - const runCommand = () => execa(PM2_PATH, pm2Args, execOptions satisfies Options); - if (raw) { - return runCommand(); - } - return runCommand() - .then((result) => { - this.logger.trace(result.stdout); - return result; - }) - .catch((result: Result) => { - this.logger.error(`PM2 error occurred from tag "${tag}": ${result.stdout}\n`); - return result; - }); - } - - /** - * Deletes the PM2 dump file. - * - * This method removes the PM2 dump file located at `~/.pm2/dump.pm2` by default. - * It logs a message indicating that the PM2 dump has been cleared. - * - * @returns A promise that resolves once the dump file is removed. - */ - async deleteDump(dumpFile = join(PM2_HOME, 'dump.pm2')) { - await rm(dumpFile, { force: true }); - this.logger.trace('PM2 dump cleared.'); - } - - async forceKillPm2Daemon() { - try { - // Find all PM2 daemon processes and kill them - const pids = (await execa('pgrep', ['-i', 'PM2'])).stdout.split('\n').filter(Boolean); - if (pids.length > 0) { - await execa('kill', ['-9', ...pids]); - this.logger.trace(`Killed PM2 daemon processes: ${pids.join(', ')}`); - } - } catch (err) { - if (err instanceof ExecaError && err.exitCode === 1) { - this.logger.trace('No PM2 daemon processes found.'); - } else { - this.logger.error(`Error force killing PM2 daemon: ${err}`); - } - } - } - - async deletePm2Home() { - if ((await fileExists(PM2_HOME)) && (await fileExists(join(PM2_HOME, 'pm2.log')))) { - await rm(PM2_HOME, { recursive: true, force: true }); - this.logger.trace('PM2 home directory cleared.'); - } else { - this.logger.trace('PM2 home directory does not exist.'); - } - } - - /** - * Ensures that the dependencies necessary for PM2 to start and operate are present. - * Creates PM2_HOME directory with proper permissions if it doesn't exist. - */ - async ensurePm2Dependencies() { - try { - // Create logs directory - await mkdir(PATHS_LOGS_DIR, { recursive: true }); - - // PM2 automatically creates and manages its home directory when the daemon starts - this.logger.trace(`PM2_HOME will be created at ${PM2_HOME} when PM2 daemon starts`); - } catch (error) { - // Log error but don't throw - let PM2 fail with its own error messages if the setup is incomplete - this.logger.error( - `Failed to fully ensure PM2 dependencies: ${error instanceof Error ? error.message : error}. PM2 may encounter issues during operation.` - ); - } - } -} diff --git a/api/src/unraid-api/cli/report.command.ts b/api/src/unraid-api/cli/report.command.ts index 1e03dea5c5..49188cf77c 100644 --- a/api/src/unraid-api/cli/report.command.ts +++ b/api/src/unraid-api/cli/report.command.ts @@ -33,9 +33,9 @@ export class ReportCommand extends CommandRunner { async report(): Promise { try { // Check if API is running - const { isUnraidApiRunning } = await import('@app/core/utils/pm2/unraid-api-running.js'); + const { isUnraidApiRunning } = await import('@app/core/utils/process/unraid-api-running.js'); const apiRunning = await isUnraidApiRunning().catch((err) => { - this.logger.debug('failed to get PM2 state with error: ' + err); + this.logger.debug('failed to check nodemon state with error: ' + err); return false; }); diff --git a/api/src/unraid-api/cli/restart.command.ts b/api/src/unraid-api/cli/restart.command.ts index 66d54a513e..166162fa6b 100644 --- a/api/src/unraid-api/cli/restart.command.ts +++ b/api/src/unraid-api/cli/restart.command.ts @@ -2,9 +2,9 @@ import { Command, CommandRunner, Option } from 'nest-commander'; import type { LogLevel } from '@app/core/log.js'; import { levels } from '@app/core/log.js'; -import { ECOSYSTEM_PATH, LOG_LEVEL } from '@app/environment.js'; +import { LOG_LEVEL } from '@app/environment.js'; import { LogService } from '@app/unraid-api/cli/log.service.js'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; +import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; export interface LogLevelOptions { logLevel?: LogLevel; @@ -22,7 +22,7 @@ export function parseLogLevelOption(val: string, allowedLevels: string[] = [...l export class RestartCommand extends CommandRunner { constructor( private readonly logger: LogService, - private readonly pm2: PM2Service + private readonly nodemon: NodemonService ) { super(); } @@ -30,23 +30,9 @@ export class RestartCommand extends CommandRunner { async run(_?: string[], options: LogLevelOptions = {}): Promise { try { this.logger.info('Restarting the Unraid API...'); - const env = { LOG_LEVEL: options.logLevel }; - const { stderr, stdout } = await this.pm2.run( - { tag: 'PM2 Restart', raw: true, extendEnv: true, env }, - 'restart', - ECOSYSTEM_PATH, - '--update-env', - '--mini-list' - ); - - if (stderr) { - this.logger.error(stderr.toString()); - process.exit(1); - } else if (stdout) { - this.logger.info(stdout.toString()); - } else { - this.logger.info('Unraid API restarted'); - } + const env = { LOG_LEVEL: options.logLevel?.toUpperCase() }; + await this.nodemon.restart({ env }); + this.logger.info('Unraid API restarted'); } catch (error) { if (error instanceof Error) { this.logger.error(error.message); diff --git a/api/src/unraid-api/cli/start.command.ts b/api/src/unraid-api/cli/start.command.ts index 64c7d890d0..61660612c9 100644 --- a/api/src/unraid-api/cli/start.command.ts +++ b/api/src/unraid-api/cli/start.command.ts @@ -3,46 +3,23 @@ import { Command, CommandRunner, Option } from 'nest-commander'; import type { LogLevel } from '@app/core/log.js'; import type { LogLevelOptions } from '@app/unraid-api/cli/restart.command.js'; import { levels } from '@app/core/log.js'; -import { ECOSYSTEM_PATH, LOG_LEVEL } from '@app/environment.js'; +import { LOG_LEVEL } from '@app/environment.js'; import { LogService } from '@app/unraid-api/cli/log.service.js'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; +import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; import { parseLogLevelOption } from '@app/unraid-api/cli/restart.command.js'; @Command({ name: 'start', description: 'Start the Unraid API' }) export class StartCommand extends CommandRunner { constructor( private readonly logger: LogService, - private readonly pm2: PM2Service + private readonly nodemon: NodemonService ) { super(); } - async cleanupPM2State() { - await this.pm2.ensurePm2Dependencies(); - await this.pm2.run({ tag: 'PM2 Stop' }, 'stop', ECOSYSTEM_PATH); - await this.pm2.run({ tag: 'PM2 Update' }, 'update'); - await this.pm2.deleteDump(); - await this.pm2.run({ tag: 'PM2 Delete' }, 'delete', ECOSYSTEM_PATH); - } - async run(_: string[], options: LogLevelOptions): Promise { this.logger.info('Starting the Unraid API'); - await this.cleanupPM2State(); - const env = { LOG_LEVEL: options.logLevel }; - const { stderr, stdout } = await this.pm2.run( - { tag: 'PM2 Start', raw: true, extendEnv: true, env }, - 'start', - ECOSYSTEM_PATH, - '--update-env', - '--mini-list' - ); - if (stdout) { - this.logger.log(stdout.toString()); - } - if (stderr) { - this.logger.error(stderr.toString()); - process.exit(1); - } + await this.nodemon.start({ env: { LOG_LEVEL: options.logLevel?.toUpperCase() } }); } @Option({ diff --git a/api/src/unraid-api/cli/status.command.ts b/api/src/unraid-api/cli/status.command.ts index 6e1b6b6e2e..489198e3b9 100644 --- a/api/src/unraid-api/cli/status.command.ts +++ b/api/src/unraid-api/cli/status.command.ts @@ -1,18 +1,13 @@ import { Command, CommandRunner } from 'nest-commander'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; +import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; @Command({ name: 'status', description: 'Check status of unraid-api service' }) export class StatusCommand extends CommandRunner { - constructor(private readonly pm2: PM2Service) { + constructor(private readonly nodemon: NodemonService) { super(); } async run(): Promise { - await this.pm2.run( - { tag: 'PM2 Status', stdio: 'inherit', raw: true }, - 'status', - 'unraid-api', - '--mini-list' - ); + await this.nodemon.status(); } } diff --git a/api/src/unraid-api/cli/stop.command.ts b/api/src/unraid-api/cli/stop.command.ts index 995dd07437..376c89c6e2 100644 --- a/api/src/unraid-api/cli/stop.command.ts +++ b/api/src/unraid-api/cli/stop.command.ts @@ -1,41 +1,28 @@ import { Command, CommandRunner, Option } from 'nest-commander'; -import { ECOSYSTEM_PATH } from '@app/environment.js'; -import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; +import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js'; interface StopCommandOptions { - delete: boolean; + force: boolean; } @Command({ name: 'stop', description: 'Stop the Unraid API', }) export class StopCommand extends CommandRunner { - constructor(private readonly pm2: PM2Service) { + constructor(private readonly nodemon: NodemonService) { super(); } @Option({ - flags: '-d, --delete', - description: 'Delete the PM2 home directory', + flags: '-f, --force', + description: 'Forcefully stop the API process', }) - parseDelete(): boolean { + parseForce(): boolean { return true; } - async run(_: string[], options: StopCommandOptions = { delete: false }) { - if (options.delete) { - await this.pm2.run({ tag: 'PM2 Kill', stdio: 'inherit' }, 'kill', '--no-autorestart'); - await this.pm2.forceKillPm2Daemon(); - await this.pm2.deletePm2Home(); - } else { - await this.pm2.run( - { tag: 'PM2 Delete', stdio: 'inherit' }, - 'delete', - ECOSYSTEM_PATH, - '--no-autorestart', - '--mini-list' - ); - } + async run(_: string[], options: StopCommandOptions = { force: false }) { + await this.nodemon.stop({ force: options.force }); } } diff --git a/api/src/unraid-api/graph/resolvers/array/array.resolver.ts b/api/src/unraid-api/graph/resolvers/array/array.resolver.ts index 40734973ea..45ad31932f 100644 --- a/api/src/unraid-api/graph/resolvers/array/array.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/array/array.resolver.ts @@ -1,9 +1,10 @@ import { Query, Resolver, Subscription } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; -import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { createSubscription } from '@app/core/pubsub.js'; import { UnraidArray } from '@app/unraid-api/graph/resolvers/array/array.model.js'; import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js'; @@ -26,6 +27,6 @@ export class ArrayResolver { resource: Resource.ARRAY, }) public async arraySubscription() { - return createSubscription(PUBSUB_CHANNEL.ARRAY); + return createSubscription(GRAPHQL_PUBSUB_CHANNEL.ARRAY); } } diff --git a/api/src/unraid-api/graph/resolvers/array/parity.resolver.ts b/api/src/unraid-api/graph/resolvers/array/parity.resolver.ts index 07b304c3c7..8ed56ab906 100644 --- a/api/src/unraid-api/graph/resolvers/array/parity.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/array/parity.resolver.ts @@ -1,10 +1,10 @@ import { Query, Resolver, Subscription } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { PubSub } from 'graphql-subscriptions'; -import { PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js'; import { ParityCheck } from '@app/unraid-api/graph/resolvers/array/parity.model.js'; import { ParityService } from '@app/unraid-api/graph/resolvers/array/parity.service.js'; @@ -33,6 +33,6 @@ export class ParityResolver { }) @Subscription(() => ParityCheck) parityHistorySubscription() { - return pubSub.asyncIterableIterator(PUBSUB_CHANNEL.PARITY); + return pubSub.asyncIterableIterator(GRAPHQL_PUBSUB_CHANNEL.PARITY); } } diff --git a/api/src/unraid-api/graph/resolvers/display/display.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/display/display.resolver.spec.ts index 884805059c..483a876237 100644 --- a/api/src/unraid-api/graph/resolvers/display/display.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/display/display.resolver.spec.ts @@ -1,6 +1,7 @@ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { DisplayResolver } from '@app/unraid-api/graph/resolvers/display/display.resolver.js'; @@ -9,9 +10,6 @@ import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/dis // Mock the pubsub module vi.mock('@app/core/pubsub.js', () => ({ createSubscription: vi.fn().mockReturnValue('mock-subscription'), - PUBSUB_CHANNEL: { - DISPLAY: 'display', - }, })); describe('DisplayResolver', () => { @@ -80,11 +78,11 @@ describe('DisplayResolver', () => { describe('displaySubscription', () => { it('should create and return subscription', async () => { - const { createSubscription, PUBSUB_CHANNEL } = await import('@app/core/pubsub.js'); + const { createSubscription } = await import('@app/core/pubsub.js'); const result = await resolver.displaySubscription(); - expect(createSubscription).toHaveBeenCalledWith(PUBSUB_CHANNEL.DISPLAY); + expect(createSubscription).toHaveBeenCalledWith(GRAPHQL_PUBSUB_CHANNEL.DISPLAY); expect(result).toBe('mock-subscription'); }); }); diff --git a/api/src/unraid-api/graph/resolvers/display/display.resolver.ts b/api/src/unraid-api/graph/resolvers/display/display.resolver.ts index 558c2b4be3..6f1e732763 100644 --- a/api/src/unraid-api/graph/resolvers/display/display.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/display/display.resolver.ts @@ -1,9 +1,10 @@ import { Query, Resolver, Subscription } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; -import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { createSubscription } from '@app/core/pubsub.js'; import { Display } from '@app/unraid-api/graph/resolvers/info/display/display.model.js'; import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js'; @@ -26,6 +27,6 @@ export class DisplayResolver { resource: Resource.DISPLAY, }) public async displaySubscription() { - return createSubscription(PUBSUB_CHANNEL.DISPLAY); + return createSubscription(GRAPHQL_PUBSUB_CHANNEL.DISPLAY); } } diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-event.service.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker-event.service.spec.ts index 933100f1bf..ab8823e08e 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-event.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-event.service.spec.ts @@ -2,11 +2,12 @@ import { Logger } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { PassThrough, Readable } from 'stream'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import Docker from 'dockerode'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Import pubsub for use in tests -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { DockerEventService } from '@app/unraid-api/graph/resolvers/docker/docker-event.service.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; @@ -46,9 +47,6 @@ vi.mock('@app/core/pubsub.js', () => ({ pubsub: { publish: vi.fn().mockResolvedValue(undefined), }, - PUBSUB_CHANNEL: { - INFO: 'info', - }, })); // Mock DockerService @@ -140,7 +138,7 @@ describe('DockerEventService', () => { expect(dockerService.clearContainerCache).toHaveBeenCalled(); expect(dockerService.getAppInfo).toHaveBeenCalled(); - expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.INFO, expect.any(Object)); + expect(pubsub.publish).toHaveBeenCalledWith(GRAPHQL_PUBSUB_CHANNEL.INFO, expect.any(Object)); }); it('should ignore non-watched actions', async () => { diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-event.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-event.service.ts index 8e34166b61..0be2febfcd 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-event.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-event.service.ts @@ -1,10 +1,11 @@ import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Readable } from 'stream'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { watch } from 'chokidar'; import Docker from 'dockerode'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { getters } from '@app/store/index.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; @@ -132,7 +133,7 @@ export class DockerEventService implements OnModuleDestroy, OnModuleInit { await this.dockerService.clearContainerCache(); // Get updated counts and publish const appInfo = await this.dockerService.getAppInfo(); - await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo); + await pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.INFO, appInfo); this.logger.debug(`Published app info update due to event: ${actionName}`); } } diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts index ba7e974f22..39843d2a22 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts @@ -2,11 +2,12 @@ import type { TestingModule } from '@nestjs/testing'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Test } from '@nestjs/testing'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import Docker from 'dockerode'; import { beforeEach, describe, expect, it, vi } from 'vitest'; // Import the mocked pubsub parts -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { ContainerState, DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; @@ -15,7 +16,7 @@ vi.mock('@app/core/pubsub.js', () => ({ pubsub: { publish: vi.fn().mockResolvedValue(undefined), }, - PUBSUB_CHANNEL: { + GRAPHQL_PUBSUB_CHANNEL: { INFO: 'info', }, })); @@ -274,7 +275,7 @@ describe('DockerService', () => { expect(mockCacheManager.del).toHaveBeenCalledWith(DockerService.CONTAINER_CACHE_KEY); expect(mockListContainers).toHaveBeenCalled(); expect(mockCacheManager.set).toHaveBeenCalled(); - expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.INFO, { + expect(pubsub.publish).toHaveBeenCalledWith(GRAPHQL_PUBSUB_CHANNEL.INFO, { info: { apps: { installed: 1, running: 1 }, }, @@ -332,7 +333,7 @@ describe('DockerService', () => { expect(mockCacheManager.del).toHaveBeenCalledWith(DockerService.CONTAINER_CACHE_KEY); expect(mockListContainers).toHaveBeenCalled(); expect(mockCacheManager.set).toHaveBeenCalled(); - expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.INFO, { + expect(pubsub.publish).toHaveBeenCalledWith(GRAPHQL_PUBSUB_CHANNEL.INFO, { info: { apps: { installed: 1, running: 0 }, }, diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker.service.ts index 5b244773f6..54bc9c88d2 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.service.ts @@ -2,10 +2,11 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { readFile } from 'fs/promises'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { type Cache } from 'cache-manager'; import Docker from 'dockerode'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { catchHandlers } from '@app/core/utils/misc/catch-handlers.js'; import { sleep } from '@app/core/utils/misc/sleep.js'; import { getters } from '@app/store/index.js'; @@ -210,7 +211,7 @@ export class DockerService { throw new Error(`Container ${id} not found after starting`); } const appInfo = await this.getAppInfo(); - await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo); + await pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.INFO, appInfo); return updatedContainer; } @@ -240,7 +241,7 @@ export class DockerService { this.logger.warn(`Container ${id} did not reach EXITED state after stop command.`); } const appInfo = await this.getAppInfo(); - await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo); + await pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.INFO, appInfo); return updatedContainer; } } diff --git a/api/src/unraid-api/graph/resolvers/info/versions/versions.model.ts b/api/src/unraid-api/graph/resolvers/info/versions/versions.model.ts index dd6fe5d880..2080cbbb91 100644 --- a/api/src/unraid-api/graph/resolvers/info/versions/versions.model.ts +++ b/api/src/unraid-api/graph/resolvers/info/versions/versions.model.ts @@ -25,8 +25,8 @@ export class PackageVersions { @Field(() => String, { nullable: true, description: 'npm version' }) npm?: string; - @Field(() => String, { nullable: true, description: 'pm2 version' }) - pm2?: string; + @Field(() => String, { nullable: true, description: 'nodemon version' }) + nodemon?: string; @Field(() => String, { nullable: true, description: 'Git version' }) git?: string; diff --git a/api/src/unraid-api/graph/resolvers/info/versions/versions.resolver.ts b/api/src/unraid-api/graph/resolvers/info/versions/versions.resolver.ts index a711a17dd1..836122b3b5 100644 --- a/api/src/unraid-api/graph/resolvers/info/versions/versions.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/info/versions/versions.resolver.ts @@ -3,6 +3,7 @@ import { ResolveField, Resolver } from '@nestjs/graphql'; import { versions } from 'systeminformation'; +import { getPackageJson } from '@app/environment.js'; import { CoreVersions, InfoVersions, @@ -34,7 +35,7 @@ export class VersionsResolver { openssl: softwareVersions.openssl, node: softwareVersions.node, npm: softwareVersions.npm, - pm2: softwareVersions.pm2, + nodemon: getPackageJson().dependencies?.nodemon, git: softwareVersions.git, nginx: softwareVersions.nginx, php: softwareVersions.php, diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts index 12b899a094..0c7fe074ab 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts @@ -2,9 +2,10 @@ import type { TestingModule } from '@nestjs/testing'; import { ScheduleModule } from '@nestjs/schedule'; import { Test } from '@nestjs/testing'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js'; import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js'; import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js'; @@ -107,7 +108,7 @@ describe('MetricsResolver Integration Tests', () => { }); // Trigger polling by simulating subscription - trackerService.subscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION); // Wait a bit for potential multiple executions await new Promise((resolve) => setTimeout(resolve, 100)); @@ -141,7 +142,7 @@ describe('MetricsResolver Integration Tests', () => { }); // Trigger polling by simulating subscription - trackerService.subscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION); // Wait a bit for potential multiple executions await new Promise((resolve) => setTimeout(resolve, 100)); @@ -155,13 +156,13 @@ describe('MetricsResolver Integration Tests', () => { const trackerService = module.get(SubscriptionTrackerService); // Trigger polling by starting subscription - trackerService.subscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION); // Wait for the polling interval to trigger (1000ms for CPU) await new Promise((resolve) => setTimeout(resolve, 1100)); expect(publishSpy).toHaveBeenCalledWith( - PUBSUB_CHANNEL.CPU_UTILIZATION, + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION, expect.objectContaining({ systemMetricsCpu: expect.objectContaining({ id: 'info/cpu-load', @@ -171,7 +172,7 @@ describe('MetricsResolver Integration Tests', () => { }) ); - trackerService.unsubscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); + trackerService.unsubscribe(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION); publishSpy.mockRestore(); }); @@ -180,13 +181,13 @@ describe('MetricsResolver Integration Tests', () => { const trackerService = module.get(SubscriptionTrackerService); // Trigger polling by starting subscription - trackerService.subscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION); // Wait for the polling interval to trigger (2000ms for memory) await new Promise((resolve) => setTimeout(resolve, 2100)); expect(publishSpy).toHaveBeenCalledWith( - PUBSUB_CHANNEL.MEMORY_UTILIZATION, + GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION, expect.objectContaining({ systemMetricsMemory: expect.objectContaining({ id: 'memory-utilization', @@ -197,7 +198,7 @@ describe('MetricsResolver Integration Tests', () => { }) ); - trackerService.unsubscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + trackerService.unsubscribe(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION); publishSpy.mockRestore(); }); @@ -214,7 +215,7 @@ describe('MetricsResolver Integration Tests', () => { vi.spyOn(service, 'generateCpuLoad').mockRejectedValueOnce(new Error('CPU error')); // Trigger polling - trackerService.subscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION); // Wait for polling interval to trigger and handle error (1000ms for CPU) await new Promise((resolve) => setTimeout(resolve, 1100)); @@ -224,7 +225,7 @@ describe('MetricsResolver Integration Tests', () => { expect.any(Error) ); - trackerService.unsubscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); + trackerService.unsubscribe(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION); loggerSpy.mockRestore(); }); @@ -241,7 +242,7 @@ describe('MetricsResolver Integration Tests', () => { vi.spyOn(service, 'generateMemoryLoad').mockRejectedValueOnce(new Error('Memory error')); // Trigger polling - trackerService.subscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION); // Wait for polling interval to trigger and handle error (2000ms for memory) await new Promise((resolve) => setTimeout(resolve, 2100)); @@ -251,7 +252,7 @@ describe('MetricsResolver Integration Tests', () => { expect.any(Error) ); - trackerService.unsubscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + trackerService.unsubscribe(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION); loggerSpy.mockRestore(); }); }); @@ -263,26 +264,30 @@ describe('MetricsResolver Integration Tests', () => { module.get(SubscriptionManagerService); // Start polling - trackerService.subscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); - trackerService.subscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION); + trackerService.subscribe(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION); // Wait a bit for subscriptions to be fully set up await new Promise((resolve) => setTimeout(resolve, 100)); // Verify subscriptions are active - expect(subscriptionManager.isSubscriptionActive(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(true); - expect(subscriptionManager.isSubscriptionActive(PUBSUB_CHANNEL.MEMORY_UTILIZATION)).toBe( - true - ); + expect( + subscriptionManager.isSubscriptionActive(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION) + ).toBe(true); + expect( + subscriptionManager.isSubscriptionActive(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION) + ).toBe(true); // Clean up the module await module.close(); // Subscriptions should be cleaned up - expect(subscriptionManager.isSubscriptionActive(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(false); - expect(subscriptionManager.isSubscriptionActive(PUBSUB_CHANNEL.MEMORY_UTILIZATION)).toBe( - false - ); + expect( + subscriptionManager.isSubscriptionActive(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION) + ).toBe(false); + expect( + subscriptionManager.isSubscriptionActive(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION) + ).toBe(false); }); }); }); diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts index cbd47e86ba..13c5f793fa 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -2,9 +2,10 @@ import { Logger, OnModuleInit } from '@nestjs/common'; import { Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js'; import { CpuPackages, CpuUtilization } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js'; import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js'; @@ -28,16 +29,16 @@ export class MetricsResolver implements OnModuleInit { onModuleInit() { // Register CPU polling with 1 second interval this.subscriptionTracker.registerTopic( - PUBSUB_CHANNEL.CPU_UTILIZATION, + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION, async () => { const payload = await this.cpuService.generateCpuLoad(); - pubsub.publish(PUBSUB_CHANNEL.CPU_UTILIZATION, { systemMetricsCpu: payload }); + pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION, { systemMetricsCpu: payload }); }, 1000 ); this.subscriptionTracker.registerTopic( - PUBSUB_CHANNEL.CPU_TELEMETRY, + GRAPHQL_PUBSUB_CHANNEL.CPU_TELEMETRY, async () => { const packageList = (await this.cpuTopologyService.generateTelemetry()) ?? []; @@ -59,7 +60,7 @@ export class MetricsResolver implements OnModuleInit { this.logger.debug(`CPU_TELEMETRY payload: ${JSON.stringify(packages)}`); // Publish the payload - pubsub.publish(PUBSUB_CHANNEL.CPU_TELEMETRY, { + pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.CPU_TELEMETRY, { systemMetricsCpuTelemetry: packages, }); @@ -70,10 +71,12 @@ export class MetricsResolver implements OnModuleInit { // Register memory polling with 2 second interval this.subscriptionTracker.registerTopic( - PUBSUB_CHANNEL.MEMORY_UTILIZATION, + GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION, async () => { const payload = await this.memoryService.generateMemoryLoad(); - pubsub.publish(PUBSUB_CHANNEL.MEMORY_UTILIZATION, { systemMetricsMemory: payload }); + pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION, { + systemMetricsMemory: payload, + }); }, 2000 ); @@ -109,7 +112,7 @@ export class MetricsResolver implements OnModuleInit { resource: Resource.INFO, }) public async systemMetricsCpuSubscription() { - return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + return this.subscriptionHelper.createTrackedSubscription(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION); } @Subscription(() => CpuPackages, { @@ -121,7 +124,7 @@ export class MetricsResolver implements OnModuleInit { resource: Resource.INFO, }) public async systemMetricsCpuTelemetrySubscription() { - return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.CPU_TELEMETRY); + return this.subscriptionHelper.createTrackedSubscription(GRAPHQL_PUBSUB_CHANNEL.CPU_TELEMETRY); } @Subscription(() => MemoryUtilization, { @@ -133,6 +136,8 @@ export class MetricsResolver implements OnModuleInit { resource: Resource.INFO, }) public async systemMetricsMemorySubscription() { - return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + return this.subscriptionHelper.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.MEMORY_UTILIZATION + ); } } diff --git a/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts b/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts index 1c582ddd33..87a1218884 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts @@ -46,7 +46,7 @@ vi.mock('@app/core/pubsub.js', () => ({ pubsub: { publish: vi.fn(), }, - PUBSUB_CHANNEL: { + GRAPHQL_PUBSUB_CHANNEL: { NOTIFICATION_OVERVIEW: 'notification_overview', NOTIFICATION_ADDED: 'notification_added', }, diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts index fe6e56ad6b..de7335f4a1 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts @@ -2,10 +2,11 @@ import { Args, Mutation, Query, ResolveField, Resolver, Subscription } from '@ne import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { AppError } from '@app/core/errors/app-error.js'; -import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { createSubscription } from '@app/core/pubsub.js'; import { Notification, NotificationData, @@ -152,7 +153,7 @@ export class NotificationsResolver { resource: Resource.NOTIFICATIONS, }) async notificationAdded() { - return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_ADDED); + return createSubscription(GRAPHQL_PUBSUB_CHANNEL.NOTIFICATION_ADDED); } @Subscription(() => NotificationOverview) @@ -161,6 +162,6 @@ export class NotificationsResolver { resource: Resource.NOTIFICATIONS, }) async notificationsOverview() { - return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW); + return createSubscription(GRAPHQL_PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW); } } diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts index 6ec780d666..c2cfdaf99b 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts @@ -3,6 +3,7 @@ import { readdir, readFile, rename, stat, unlink, writeFile } from 'fs/promises' import { basename, join } from 'path'; import type { Stats } from 'fs'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { FSWatcher, watch } from 'chokidar'; import { ValidationError } from 'class-validator'; import { execa } from 'execa'; @@ -12,7 +13,7 @@ import { encode as encodeIni } from 'ini'; import { v7 as uuidv7 } from 'uuid'; import { AppError } from '@app/core/errors/app-error.js'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { NotificationIni } from '@app/core/types/states/notification.js'; import { fileExists } from '@app/core/utils/files/file-exists.js'; import { parseConfig } from '@app/core/utils/misc/parse-config.js'; @@ -118,7 +119,7 @@ export class NotificationsService { if (type === NotificationType.UNREAD) { this.publishOverview(); - pubsub.publish(PUBSUB_CHANNEL.NOTIFICATION_ADDED, { + pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.NOTIFICATION_ADDED, { notificationAdded: notification, }); } @@ -137,7 +138,7 @@ export class NotificationsService { } private publishOverview(overview = NotificationsService.overview) { - return pubsub.publish(PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW, { + return pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW, { notificationsOverview: overview, }); } diff --git a/api/src/unraid-api/graph/resolvers/owner/owner.resolver.ts b/api/src/unraid-api/graph/resolvers/owner/owner.resolver.ts index c4f20ca5d2..1dd550a735 100644 --- a/api/src/unraid-api/graph/resolvers/owner/owner.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/owner/owner.resolver.ts @@ -2,9 +2,10 @@ import { ConfigService } from '@nestjs/config'; import { Query, Resolver, Subscription } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; -import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { createSubscription } from '@app/core/pubsub.js'; import { Owner } from '@app/unraid-api/graph/resolvers/owner/owner.model.js'; // Question: should we move this into the connect plugin, or should this always be available? @@ -39,6 +40,6 @@ export class OwnerResolver { resource: Resource.OWNER, }) public ownerSubscription() { - return createSubscription(PUBSUB_CHANNEL.OWNER); + return createSubscription(GRAPHQL_PUBSUB_CHANNEL.OWNER); } } diff --git a/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts b/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts index 8bcc2e9e3f..980e966c66 100644 --- a/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts @@ -3,9 +3,10 @@ import { ConfigService } from '@nestjs/config'; import { Query, Resolver, Subscription } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; -import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { createSubscription } from '@app/core/pubsub.js'; import { getters } from '@app/store/index.js'; import { MinigraphStatus } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js'; import { @@ -42,7 +43,7 @@ export class ServerResolver { resource: Resource.SERVERS, }) public async serversSubscription() { - return createSubscription(PUBSUB_CHANNEL.SERVERS); + return createSubscription(GRAPHQL_PUBSUB_CHANNEL.SERVERS); } private getLocalServer(): ServerModel { diff --git a/api/src/unraid-api/graph/services/subscription-helper.service.spec.ts b/api/src/unraid-api/graph/services/subscription-helper.service.spec.ts index 42ec4815cd..c6c7d3e2d1 100644 --- a/api/src/unraid-api/graph/services/subscription-helper.service.spec.ts +++ b/api/src/unraid-api/graph/services/subscription-helper.service.spec.ts @@ -1,9 +1,10 @@ import { Logger } from '@nestjs/common'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { PubSub } from 'graphql-subscriptions'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { pubsub } from '@app/core/pubsub.js'; import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js'; import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; @@ -28,7 +29,9 @@ describe('SubscriptionHelperService', () => { describe('createTrackedSubscription', () => { it('should create an async iterator that tracks subscriptions', async () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); expect(iterator).toBeDefined(); expect(iterator.next).toBeDefined(); @@ -37,29 +40,35 @@ describe('SubscriptionHelperService', () => { expect(iterator[Symbol.asyncIterator]).toBeDefined(); // Should have subscribed - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); }); it('should return itself when Symbol.asyncIterator is called', () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); expect(iterator[Symbol.asyncIterator]()).toBe(iterator); }); it('should unsubscribe when return() is called', async () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); await iterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); }); it('should unsubscribe when throw() is called', async () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); try { await iterator.throw?.(new Error('Test error')); @@ -67,14 +76,14 @@ describe('SubscriptionHelperService', () => { // Expected to throw } - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); }); }); describe('integration with pubsub', () => { it('should receive published messages', async () => { const iterator = helperService.createTrackedSubscription<{ cpuUtilization: any }>( - PUBSUB_CHANNEL.CPU_UTILIZATION + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION ); const testData = { @@ -92,7 +101,7 @@ describe('SubscriptionHelperService', () => { await new Promise((resolve) => setTimeout(resolve, 10)); // Publish a message - await (pubsub as PubSub).publish(PUBSUB_CHANNEL.CPU_UTILIZATION, testData); + await (pubsub as PubSub).publish(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION, testData); // Wait for the message const result = await consumePromise; @@ -107,21 +116,27 @@ describe('SubscriptionHelperService', () => { // Register handlers to verify start/stop behavior const onStart = vi.fn(); const onStop = vi.fn(); - trackerService.registerTopic(PUBSUB_CHANNEL.CPU_UTILIZATION, onStart, onStop); + trackerService.registerTopic(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION, onStart, onStop); // Create first subscriber - const iterator1 = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + const iterator1 = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); expect(onStart).toHaveBeenCalledTimes(1); // Create second subscriber - const iterator2 = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(2); + const iterator2 = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(2); expect(onStart).toHaveBeenCalledTimes(1); // Should not call again // Create third subscriber - const iterator3 = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(3); + const iterator3 = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(3); // Set up consumption promises first const consume1 = iterator1.next(); @@ -133,7 +148,7 @@ describe('SubscriptionHelperService', () => { // Publish a message - all should receive it const testData = { cpuUtilization: { id: 'test', load: 75, cpus: [] } }; - await (pubsub as PubSub).publish(PUBSUB_CHANNEL.CPU_UTILIZATION, testData); + await (pubsub as PubSub).publish(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION, testData); const [result1, result2, result3] = await Promise.all([consume1, consume2, consume3]); @@ -143,17 +158,17 @@ describe('SubscriptionHelperService', () => { // Clean up first subscriber await iterator1.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(2); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(2); expect(onStop).not.toHaveBeenCalled(); // Clean up second subscriber await iterator2.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); expect(onStop).not.toHaveBeenCalled(); // Clean up last subscriber - should trigger onStop await iterator3.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); expect(onStop).toHaveBeenCalledTimes(1); }); @@ -161,18 +176,26 @@ describe('SubscriptionHelperService', () => { const iterations = 10; for (let i = 0; i < iterations; i++) { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe( + 1 + ); await iterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe( + 0 + ); } }); it('should properly clean up on error', async () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); const testError = new Error('Test error'); @@ -183,13 +206,15 @@ describe('SubscriptionHelperService', () => { expect(error).toBe(testError); } - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); }); it('should log debug messages for subscription lifecycle', async () => { vi.clearAllMocks(); - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); expect(loggerSpy).toHaveBeenCalledWith( expect.stringContaining('Subscription added for topic') @@ -205,9 +230,9 @@ describe('SubscriptionHelperService', () => { describe('different topic types', () => { it('should handle INFO channel subscriptions', async () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.INFO); + const iterator = helperService.createTrackedSubscription(GRAPHQL_PUBSUB_CHANNEL.INFO); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.INFO)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.INFO)).toBe(1); // Set up consumption promise first const consumePromise = iterator.next(); @@ -216,47 +241,51 @@ describe('SubscriptionHelperService', () => { await new Promise((resolve) => setTimeout(resolve, 10)); const testData = { info: { id: 'test-info' } }; - await (pubsub as PubSub).publish(PUBSUB_CHANNEL.INFO, testData); + await (pubsub as PubSub).publish(GRAPHQL_PUBSUB_CHANNEL.INFO, testData); const result = await consumePromise; expect(result.value).toEqual(testData); await iterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.INFO)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.INFO)).toBe(0); }); it('should track multiple different topics independently', async () => { - const cpuIterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); - const infoIterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.INFO); + const cpuIterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); + const infoIterator = helperService.createTrackedSubscription(GRAPHQL_PUBSUB_CHANNEL.INFO); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.INFO)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.INFO)).toBe(1); const allCounts = trackerService.getAllSubscriberCounts(); - expect(allCounts.get(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); - expect(allCounts.get(PUBSUB_CHANNEL.INFO)).toBe(1); + expect(allCounts.get(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(allCounts.get(GRAPHQL_PUBSUB_CHANNEL.INFO)).toBe(1); await cpuIterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.INFO)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.INFO)).toBe(1); await infoIterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.INFO)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.INFO)).toBe(0); }); }); describe('edge cases', () => { it('should handle return() called multiple times', async () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1); await iterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); // Second return should be idempotent await iterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); // Check that idempotent message was logged expect(loggerSpy).toHaveBeenCalledWith( @@ -265,7 +294,9 @@ describe('SubscriptionHelperService', () => { }); it('should handle async iterator protocol correctly', async () => { - const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); + const iterator = helperService.createTrackedSubscription( + GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION + ); // Test that it works in for-await loop (would use Symbol.asyncIterator) const receivedMessages: any[] = []; @@ -285,7 +316,7 @@ describe('SubscriptionHelperService', () => { // Publish messages for (let i = 0; i < maxMessages; i++) { - await (pubsub as PubSub).publish(PUBSUB_CHANNEL.CPU_UTILIZATION, { + await (pubsub as PubSub).publish(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION, { cpuUtilization: { id: `test-${i}`, load: i * 10, cpus: [] }, }); } @@ -300,7 +331,7 @@ describe('SubscriptionHelperService', () => { // Clean up await iterator.return?.(); - expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); + expect(trackerService.getSubscriberCount(GRAPHQL_PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0); }); }); }); diff --git a/api/src/unraid-api/graph/services/subscription-helper.service.ts b/api/src/unraid-api/graph/services/subscription-helper.service.ts index 07adef005d..8ab3d94f28 100644 --- a/api/src/unraid-api/graph/services/subscription-helper.service.ts +++ b/api/src/unraid-api/graph/services/subscription-helper.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; -import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js'; + +import { createSubscription } from '@app/core/pubsub.js'; import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; /** @@ -21,7 +23,7 @@ import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subsc * \@Subscription(() => MetricsUpdate) * async metricsSubscription() { * // Topic must be registered first via SubscriptionTrackerService - * return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.METRICS); + * return this.subscriptionHelper.createTrackedSubscription(GRAPHQL_PUBSUB_CHANNEL.METRICS); * } */ @Injectable() @@ -33,7 +35,9 @@ export class SubscriptionHelperService { * @param topic The subscription topic/channel to subscribe to * @returns A proxy async iterator with automatic cleanup */ - public createTrackedSubscription(topic: PUBSUB_CHANNEL | string): AsyncIterableIterator { + public createTrackedSubscription( + topic: GRAPHQL_PUBSUB_CHANNEL | string + ): AsyncIterableIterator { const innerIterator = createSubscription(topic); // Subscribe when the subscription starts diff --git a/api/src/unraid-api/main.ts b/api/src/unraid-api/main.ts index 4b753abfaa..cc07b5b639 100644 --- a/api/src/unraid-api/main.ts +++ b/api/src/unraid-api/main.ts @@ -140,16 +140,6 @@ export async function bootstrapNestServer(): Promise { apiLogger.info('Server listening on %s', result); } - // This 'ready' signal tells pm2 that the api has started. - // PM2 documents this as Graceful Start or Clean Restart. - // See https://pm2.keymetrics.io/docs/usage/signals-clean-restart/ - if (process.send) { - process.send('ready'); - } else { - apiLogger.warn( - 'Warning: process.send is unavailable. This will affect IPC communication with PM2.' - ); - } apiLogger.info('Nest Server is now listening'); return app; diff --git a/package.json b/package.json index 9cd390f0a2..16a8536917 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dev": "pnpm -r dev", "unraid:deploy": "pnpm -r unraid:deploy", "test": "pnpm -r test", + "test:system": "pnpm --filter @unraid/system-integration-tests test", "test:watch": "pnpm -r --parallel test:watch", "lint": "pnpm -r lint", "lint:fix": "pnpm -r lint:fix", @@ -72,6 +73,9 @@ ], "unraid-ui/**/*.{js,ts,tsx,vue}": [ "pnpm --filter @unraid/ui lint:fix" + ], + "tests/system-integration/**/*.ts": [ + "pnpm --filter @unraid/system-integration-tests lint:fix" ] }, "packageManager": "pnpm@10.15.0" 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 0d14d33a93..d653bab3c5 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 8db98eb3a4..4930430442 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 @@ -850,6 +853,36 @@ importers: specifier: 3.2.4 version: 3.2.4(@types/node@22.18.0)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1) + tests/system-integration: + devDependencies: + '@eslint/js': + specifier: ^9.34.0 + version: 9.34.0 + eslint: + specifier: ^9.34.0 + version: 9.34.0(jiti@2.5.1) + eslint-plugin-unicorn: + specifier: ^62.0.0 + version: 62.0.0(eslint@9.34.0(jiti@2.5.1)) + execa: + specifier: ^9.6.0 + version: 9.6.0 + jiti: + specifier: ^2.5.1 + version: 2.5.1 + prettier: + specifier: ^3.6.2 + version: 3.6.2 + typescript: + specifier: ^5.9.2 + version: 5.9.2 + typescript-eslint: + specifier: ^8.41.0 + version: 8.41.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@22.18.0)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1) + unraid-ui: dependencies: '@headlessui/vue': @@ -1614,6 +1647,10 @@ packages: resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -2477,6 +2514,12 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/regexpp@4.12.1': resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -2493,6 +2536,10 @@ packages: resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/eslintrc@3.3.1': resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2509,6 +2556,10 @@ packages: resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@faker-js/faker@10.0.0': resolution: {integrity: sha512-UollFEUkVXutsaP+Vndjxar40Gs5JL2HeLcl8xO1QAjJgOdhc3OmBFWyEylS+RddWaaBiAzH+5/17PLQJwDiLw==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} @@ -4028,20 +4079,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 +4801,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 +4966,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 +4981,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 +5811,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 +5854,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 +5961,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 +5989,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==} @@ -6051,14 +6070,14 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.9.6: + resolution: {integrity: sha512-v9BVVpOTLB59C9E7aSnmIF8h7qRsFpx+A2nugVMTszEOMcfjlZMsXRm4LF23I3Z9AJxc8ANpIvzbzONoX9VJlg==} + hasBin: true + basic-auth@2.0.1: 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 +6101,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} @@ -6126,6 +6137,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} @@ -6146,6 +6162,10 @@ packages: resolution: {integrity: sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==} engines: {node: '>=10.0.0'} + builtin-modules@5.0.0: + resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} + engines: {node: '>=18.20'} + bun-types@1.2.21: resolution: {integrity: sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw==} peerDependencies: @@ -6233,6 +6253,9 @@ packages: caniuse-lite@1.0.30001731: resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==} + caniuse-lite@1.0.30001760: + resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} + capital-case@1.0.4: resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} @@ -6273,9 +6296,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'} @@ -6295,6 +6315,10 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + ci-info@4.3.1: + resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} + engines: {node: '>=8'} + citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} @@ -6307,6 +6331,10 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + clean-regexp@1.0.0: + resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} + engines: {node: '>=4'} + cli-color@1.4.0: resolution: {integrity: sha512-xu6RvQqqrWEo6MPR1eixqGPywhYBHRs653F9jfXB2Hx4jdM/3WxiNE1vppRmxtMIfl16SFYTpYlrnqH/HsK/2w==} @@ -6330,10 +6358,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 +6463,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==} @@ -6576,6 +6597,9 @@ packages: resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} engines: {node: '>=12.13'} + core-js-compat@3.47.0: + resolution: {integrity: sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==} + core-js@2.6.12: resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==} deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. @@ -6630,9 +6654,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 +6745,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 +6753,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 +6778,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 +6827,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 +6910,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'} @@ -7080,6 +7075,9 @@ packages: electron-to-chromium@1.5.192: resolution: {integrity: sha512-rP8Ez0w7UNw/9j5eSXCe10o1g/8B1P5SM90PCCMVkIRQn2R0LEHWz4Eh9RnxkniuDe1W0cTSOB3MLlkTGDcuCg==} + electron-to-chromium@1.5.267: + resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + embla-carousel-auto-height@8.6.0: resolution: {integrity: sha512-/HrJQOEM6aol/oF33gd2QlINcXy3e19fJWvHDuHWp2bpyTa+2dm9tVVJak30m2Qy6QyQ6Fc8DkImtv7pxWOJUQ==} peerDependencies: @@ -7151,10 +7149,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 +7401,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 @@ -7520,6 +7509,12 @@ packages: eslint: '>=8' storybook: ^9.1.3 + eslint-plugin-unicorn@62.0.0: + resolution: {integrity: sha512-HIlIkGLkvf29YEiS/ImuDZQbP12gWyx5i3C6XrRxMvVdqMroCI9qoVYCoIl17ChN+U89pn9sVwLxhIWj5nEc7g==} + engines: {node: ^20.10.0 || >=21.0.0} + peerDependencies: + eslint: '>=9.38.0' + eslint-plugin-vue@10.4.0: resolution: {integrity: sha512-K6tP0dW8FJVZLQxa2S7LcE1lLw3X8VvB3t887Q6CLrFVxHYBXGANbXvwNzYIu6Ughx1bSJ5BDT0YB3ybPT39lw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -7602,9 +7597,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 +7666,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 +7689,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 +7742,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==} @@ -7820,6 +7803,10 @@ packages: find-package-json@1.2.0: resolution: {integrity: sha512-+SOGcLGYDJHtyqHd87ysBhmaeQ95oWspDKnMXBrnQ9Eq4OkLNqejgoaD8xVWu6GPa0B6roa6KinCMEMcVeqONw==} + find-up-simple@1.0.1: + resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} + engines: {node: '>=18'} + find-up@3.0.0: resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} engines: {node: '>=6'} @@ -8001,25 +7988,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==} @@ -8078,6 +8050,10 @@ packages: resolution: {integrity: sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==} engines: {node: '>=18'} + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + globalthis@1.0.4: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} @@ -8408,6 +8384,10 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -8454,10 +8434,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==} @@ -8506,6 +8482,10 @@ packages: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} + is-builtin-module@5.0.0: + resolution: {integrity: sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==} + engines: {node: '>=18.20'} + is-bun-module@2.0.0: resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} @@ -8818,9 +8798,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 +8811,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 +9389,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 +9403,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 +9482,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 +9522,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==} @@ -9639,6 +9596,9 @@ packages: node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + node-window-polyfill@1.0.4: resolution: {integrity: sha512-Od/jDxv5w7gtZfIS+Czy0UgLQldtituEjT9djgykQK4yq/hKySc3GXTXuUvxKvpM+J/+AwO789ojLmq2Jk8coQ==} @@ -9912,14 +9872,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 +10051,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,28 +10116,9 @@ 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 + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} portfinder@1.0.35: resolution: {integrity: sha512-73JaFg4NwYNAufDtS5FsFu/PdM49ahJrO1i44aCRsDWju1z5wuGDaqyFUQWR6aJoK2JPDWlaYYAGFNIGTSUHSw==} @@ -10512,9 +10437,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 +10461,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 +10577,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==} @@ -10746,6 +10657,10 @@ packages: resolution: {integrity: sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==} engines: {node: '>=12'} + regjsparser@0.13.0: + resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} + hasBin: true + rehackt@0.1.0: resolution: {integrity: sha512-7kRDOuLHB87D/JESKxQoRwv4DzbIdwkAGQ7p6QKGdVlY1IZheUnVhlk/4UZlNUVxdAXpyxikE3URsG067ybVzw==} peerDependencies: @@ -10787,10 +10702,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 +10827,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'} @@ -10996,6 +10904,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + send@0.19.0: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} @@ -11073,9 +10986,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 +11056,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 +11117,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==} @@ -11359,6 +11254,10 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strip-indent@4.1.1: + resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} + engines: {node: '>=12'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -11696,9 +11595,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 +11609,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'} @@ -12104,6 +11993,12 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + update-browserslist-db@1.2.2: + resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + upper-case-first@2.0.2: resolution: {integrity: sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==} @@ -12406,10 +12301,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 +12325,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==} @@ -13329,6 +13220,8 @@ snapshots: '@babel/helper-validator-identifier@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.27.6': @@ -14023,6 +13916,11 @@ snapshots: eslint: 9.34.0(jiti@2.5.1) eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.0(eslint@9.34.0(jiti@2.5.1))': + dependencies: + eslint: 9.34.0(jiti@2.5.1) + eslint-visitor-keys: 3.4.3 + '@eslint-community/regexpp@4.12.1': {} '@eslint/config-array@0.21.0': @@ -14039,6 +13937,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + '@eslint/eslintrc@3.3.1': dependencies: ajv: 6.12.6 @@ -14062,6 +13964,11 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + '@faker-js/faker@10.0.0': {} '@fastify/ajv-compiler@4.0.2': @@ -16053,56 +15960,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 +16357,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 +16585,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 +16777,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 +16791,8 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/retry@0.12.5': {} + '@types/semver@7.7.0': {} '@types/send@0.17.4': @@ -17885,14 +17746,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 +17776,6 @@ snapshots: ansi_up@6.0.6: {} - ansis@4.0.0-node10: {} - ansis@4.1.0: {} anymatch@3.1.3: @@ -18065,10 +17916,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 +17943,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 +17981,7 @@ snapshots: axios@0.26.1: dependencies: - follow-redirects: 1.15.9(debug@4.3.7) + follow-redirects: 1.15.9 transitivePeerDependencies: - debug @@ -18199,12 +18042,12 @@ snapshots: base64-js@1.5.1: {} + baseline-browser-mapping@2.9.6: {} + basic-auth@2.0.1: dependencies: safe-buffer: 5.1.2 - basic-ftp@5.0.5: {} - bcrypt-pbkdf@1.0.2: dependencies: tweetnacl: 0.14.5 @@ -18229,12 +18072,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 @@ -18284,6 +18123,14 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.1) + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.6 + caniuse-lite: 1.0.30001760 + electron-to-chromium: 1.5.267 + node-releases: 2.0.27 + update-browserslist-db: 1.2.2(browserslist@4.28.1) + bser@2.1.1: dependencies: node-int64: 0.4.0 @@ -18305,6 +18152,8 @@ snapshots: buildcheck@0.0.6: optional: true + builtin-modules@5.0.0: {} + bun-types@1.2.21(@types/react@19.0.8): dependencies: '@types/node': 22.18.0 @@ -18417,6 +18266,8 @@ snapshots: caniuse-lite@1.0.30001731: {} + caniuse-lite@1.0.30001760: {} + capital-case@1.0.4: dependencies: no-case: 3.0.4 @@ -18489,8 +18340,6 @@ snapshots: chardet@2.1.0: {} - charm@0.1.2: {} - check-error@2.1.1: {} chokidar@3.6.0: @@ -18513,6 +18362,8 @@ snapshots: chownr@3.0.0: {} + ci-info@4.3.1: {} + citty@0.1.6: dependencies: consola: 3.4.2 @@ -18529,6 +18380,10 @@ snapshots: dependencies: clsx: 2.1.1 + clean-regexp@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + cli-color@1.4.0: dependencies: ansi-regex: 2.1.1 @@ -18556,10 +18411,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 +18500,6 @@ snapshots: commander@14.0.0: {} - commander@2.15.1: {} - commander@2.20.3: {} commander@5.1.0: {} @@ -18779,6 +18628,10 @@ snapshots: dependencies: is-what: 4.1.16 + core-js-compat@3.47.0: + dependencies: + browserslist: 4.28.1 + core-js@2.6.12: {} core-util-is@1.0.3: {} @@ -18844,8 +18697,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 +18820,6 @@ snapshots: csv-parse@5.6.0: {} - culvert@0.1.2: {} - d@1.0.2: dependencies: es5-ext: 0.10.64 @@ -18978,8 +18827,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 +18856,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 +18872,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 +18955,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: {} @@ -19269,6 +19102,8 @@ snapshots: electron-to-chromium@1.5.192: {} + electron-to-chromium@1.5.267: {} + embla-carousel-auto-height@8.6.0(embla-carousel@8.6.0): dependencies: embla-carousel: 8.6.0 @@ -19327,10 +19162,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 +19502,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) @@ -19802,6 +19625,28 @@ snapshots: - supports-color - typescript + eslint-plugin-unicorn@62.0.0(eslint@9.34.0(jiti@2.5.1)): + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.34.0(jiti@2.5.1)) + '@eslint/plugin-kit': 0.4.1 + change-case: 5.4.4 + ci-info: 4.3.1 + clean-regexp: 1.0.0 + core-js-compat: 3.47.0 + eslint: 9.34.0(jiti@2.5.1) + esquery: 1.6.0 + find-up-simple: 1.0.1 + globals: 16.5.0 + indent-string: 5.0.0 + is-builtin-module: 5.0.0 + jsesc: 3.1.0 + pluralize: 8.0.0 + regexp-tree: 0.1.27 + regjsparser: 0.13.0 + semver: 7.7.3 + strip-indent: 4.1.1 + eslint-plugin-vue@10.4.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1))(vue-eslint-parser@10.2.0(eslint@9.34.0(jiti@2.5.1))): dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@9.34.0(jiti@2.5.1)) @@ -19910,8 +19755,6 @@ snapshots: event-target-shim@5.0.1: {} - eventemitter2@5.0.1: {} - eventemitter2@6.4.9: {} eventemitter3@3.1.2: {} @@ -20029,12 +19872,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 +19894,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 +19985,6 @@ snapshots: transitivePeerDependencies: - encoding - fclone@1.0.11: {} - fd-package-json@2.0.0: dependencies: walk-up-path: 4.0.0 @@ -20220,6 +20053,8 @@ snapshots: find-package-json@1.2.0: {} + find-up-simple@1.0.1: {} + find-up@3.0.0: dependencies: locate-path: 3.0.0 @@ -20254,9 +20089,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 +20249,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 +20258,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 @@ -20516,6 +20335,8 @@ snapshots: globals@16.3.0: {} + globals@16.5.0: {} + globalthis@1.0.4: dependencies: define-properties: 1.2.1 @@ -20752,7 +20573,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 @@ -20847,6 +20668,8 @@ snapshots: indent-string@4.0.0: {} + indent-string@5.0.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -20932,11 +20755,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: {} @@ -20986,6 +20804,10 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-builtin-module@5.0.0: + dependencies: + builtin-modules: 5.0.0 + is-bun-module@2.0.0: dependencies: semver: 7.7.2 @@ -21255,13 +21077,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 +21087,6 @@ snapshots: dependencies: argparse: 2.0.1 - jsbn@1.1.0: {} - jsdom@26.1.0: dependencies: cssstyle: 4.5.0 @@ -21815,8 +21628,6 @@ snapshots: mkdirp-classic@0.5.3: {} - mkdirp@1.0.4: {} - mkdirp@3.0.1: {} mlly@1.7.4: @@ -21835,8 +21646,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 +21705,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 +21750,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): @@ -22102,6 +21901,8 @@ snapshots: node-releases@2.0.19: {} + node-releases@2.0.27: {} + node-window-polyfill@1.0.4: dependencies: ws: 7.5.10 @@ -22580,24 +22381,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 +22529,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,78 +22614,7 @@ 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 + pluralize@8.0.0: {} portfinder@1.0.35: dependencies: @@ -23162,10 +22865,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 +22906,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 +23046,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 @@ -23468,6 +23148,10 @@ snapshots: dependencies: rc: 1.2.8 + regjsparser@0.13.0: + dependencies: + jsesc: 3.1.0 + rehackt@0.1.0(@types/react@19.0.8)(react@19.1.0): optionalDependencies: '@types/react': 19.0.8 @@ -23525,14 +23209,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 +23356,6 @@ snapshots: dependencies: queue-microtask: 1.2.3 - run-series@1.1.9: {} - rxjs@6.6.7: dependencies: tslib: 1.14.1 @@ -23751,6 +23425,8 @@ snapshots: semver@7.7.2: {} + semver@7.7.3: {} + send@0.19.0: dependencies: debug: 2.6.9 @@ -23924,8 +23600,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 +23682,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 +23689,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 +23737,6 @@ snapshots: sprintf-js@1.0.3: {} - sprintf-js@1.1.2: {} - sprintf-js@1.1.3: {} ssh2@1.16.0: @@ -24237,6 +23894,8 @@ snapshots: dependencies: min-indent: 1.0.1 + strip-indent@4.1.1: {} + strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} @@ -24570,8 +24229,6 @@ snapshots: tslib@1.14.1: {} - tslib@1.9.3: {} - tslib@2.4.1: {} tslib@2.6.3: {} @@ -24585,17 +24242,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 @@ -25001,6 +24651,12 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + update-browserslist-db@1.2.2(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + upper-case-first@2.0.2: dependencies: tslib: 2.8.1 @@ -25311,13 +24967,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 +24988,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/pnpm-workspace.yaml b/pnpm-workspace.yaml index c032cc1e14..0e40d11bd3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,3 +5,4 @@ packages: - "./unraid-ui" - "./web" - "./packages/*" + - "./tests/system-integration" diff --git a/tests/system-integration/.prettierrc.cjs b/tests/system-integration/.prettierrc.cjs new file mode 100644 index 0000000000..f0ea83b21d --- /dev/null +++ b/tests/system-integration/.prettierrc.cjs @@ -0,0 +1,11 @@ +/** + * @see https://prettier.io/docs/en/configuration.html + * @type {import("prettier").Config} + */ +module.exports = { + trailingComma: 'es5', + tabWidth: 4, + semi: true, + singleQuote: true, + printWidth: 105, +}; diff --git a/tests/system-integration/README.md b/tests/system-integration/README.md new file mode 100644 index 0000000000..dd489d93b0 --- /dev/null +++ b/tests/system-integration/README.md @@ -0,0 +1,39 @@ +# System Integration Tests + +Integration tests that run against a live Unraid server via SSH. + +## Prerequisites + +- SSH key-based authentication to the target Unraid server +- `unraid-api` installed on the target server + +## Usage + +```bash +# Run tests +SERVER=tower pnpm test + +# From monorepo root +SERVER=tower pnpm test:system +``` + +## Troubleshooting + +### SSH Connection Fails + +Ensure SSH key authentication is configured: + +```bash +ssh root@tower echo "Connected" + +# If prompted for password: +ssh-copy-id root@tower +``` + +### Processes Not Cleaned Up + +If tests fail and leave processes running: + +```bash +ssh root@tower 'unraid-api stop --force; pkill -f nodemon; pkill -f main.js' +``` diff --git a/tests/system-integration/eslint.config.ts b/tests/system-integration/eslint.config.ts new file mode 100644 index 0000000000..b1e095b5c5 --- /dev/null +++ b/tests/system-integration/eslint.config.ts @@ -0,0 +1,32 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import unicorn from 'eslint-plugin-unicorn'; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.ts'], + plugins: { + unicorn, + }, + rules: { + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-explicit-any': 'off', + 'no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 1 }], + 'eol-last': ['error', 'always'], + 'unicorn/numeric-separators-style': [ + 'error', + { + number: { + minimumDigits: 5, + groupLength: 3, + }, + }, + ], + }, + }, + { + ignores: ['node_modules/**/*', 'dist/**/*'], + } +); diff --git a/tests/system-integration/package.json b/tests/system-integration/package.json new file mode 100644 index 0000000000..58fa78c524 --- /dev/null +++ b/tests/system-integration/package.json @@ -0,0 +1,26 @@ +{ + "name": "@unraid/system-integration-tests", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "System integration tests for unraid-api", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "lint": "pnpm lint:eslint && pnpm lint:prettier", + "lint:eslint": "eslint --cache --config eslint.config.ts src/", + "lint:prettier": "prettier --check \"src/**/*.ts\"", + "lint:fix": "eslint --cache --fix --config eslint.config.ts src/ && prettier --write \"src/**/*.ts\"" + }, + "devDependencies": { + "@eslint/js": "^9.34.0", + "eslint": "^9.34.0", + "eslint-plugin-unicorn": "^62.0.0", + "execa": "^9.6.0", + "jiti": "^2.5.1", + "prettier": "^3.6.2", + "typescript": "^5.9.2", + "typescript-eslint": "^8.41.0", + "vitest": "^3.2.4" + } +} diff --git a/tests/system-integration/src/helpers/api-lifecycle.ts b/tests/system-integration/src/helpers/api-lifecycle.ts new file mode 100644 index 0000000000..a7efe08b91 --- /dev/null +++ b/tests/system-integration/src/helpers/api-lifecycle.ts @@ -0,0 +1,257 @@ +/** + * @fileoverview API lifecycle management helpers for testing unraid-api daemon operations. + * Provides high-level functions for starting, stopping, and managing the API daemon state. + * + * These helpers wrap the `unraid-api` CLI commands and provide proper wait/polling + * logic to ensure operations complete before returning. + * + * @example + * ```typescript + * // Test setup + * beforeEach(async () => { + * await cleanup(); // Ensure clean state + * }); + * + * // Start and verify + * await startApi(); + * const status = await getStatus(); + * expect(status).toMatch(/running/i); + * + * // Stop and cleanup + * await stopApi(); + * ``` + */ + +import { remoteExec, remoteExecSafe } from './ssh.js'; +import { getRemotePid, isProcessRunning, countUnraidApiProcesses, REMOTE_PID_PATH } from './process.js'; +import { sleep, TEN_SECONDS } from './utils.js'; + +/** + * Default timeout for wait operations in milliseconds. + */ +const DEFAULT_TIMEOUT = TEN_SECONDS; + +/** + * Waits for the API to start by polling for PID file existence and process running state. + * + * @param timeout - Maximum time to wait in milliseconds (default: 10000) + * @returns `true` if the API started within the timeout, `false` otherwise + * + * @example + * ```typescript + * await remoteExec('unraid-api start'); + * const started = await waitForStart(15000); + * if (!started) { + * throw new Error('API failed to start'); + * } + * ``` + */ +export async function waitForStart(timeout = DEFAULT_TIMEOUT): Promise { + const deadline = Date.now() + timeout; + + while (Date.now() < deadline) { + const pid = await getRemotePid(); + if (pid && (await isProcessRunning(pid))) { + return true; + } + await sleep(1000); + } + + return false; +} + +/** + * Waits for the API to stop by polling until PID file is removed or process is not running. + * + * @param timeout - Maximum time to wait in milliseconds (default: 10000) + * @returns `true` if the API stopped within the timeout, `false` otherwise + * + * @example + * ```typescript + * await remoteExec('unraid-api stop'); + * const stopped = await waitForStop(); + * expect(stopped).toBe(true); + * ``` + */ +export async function waitForStop(timeout = DEFAULT_TIMEOUT): Promise { + const deadline = Date.now() + timeout; + + while (Date.now() < deadline) { + const pid = await getRemotePid(); + if (!pid) { + return true; + } + if (!(await isProcessRunning(pid))) { + return true; + } + await sleep(1000); + } + + return false; +} + +/** + * Waits for all API-related processes (nodemon and main.js) to terminate. + * + * @param timeout - Maximum time to wait in milliseconds (default: 10000) + * @returns `true` if all processes stopped within the timeout, `false` otherwise + * + * @example + * ```typescript + * await stopApi(); + * const allStopped = await waitForAllProcessesStop(15000); + * expect(allStopped).toBe(true); + * ``` + */ +export async function waitForAllProcessesStop(timeout = DEFAULT_TIMEOUT): Promise { + const deadline = Date.now() + timeout; + + while (Date.now() < deadline) { + const count = await countUnraidApiProcesses(); + if (count === 0) { + return true; + } + await sleep(1000); + } + + return false; +} + +/** + * Comprehensive cleanup function that ensures all API processes are terminated. + * + * Performs a multi-step cleanup process: + * 1. Attempts graceful stop via `unraid-api stop` + * 2. If processes remain, force kills nodemon first (prevents respawning) + * 3. Then force kills any remaining main.js processes + * 4. Removes the PID file + * 5. As a last resort, kills processes by explicit PID + * + * This function is designed to be called in test setup/teardown hooks to ensure + * a clean state between tests. + * + * @example + * ```typescript + * beforeEach(async () => { + * await cleanup(); + * }); + * + * afterEach(async () => { + * await cleanup(); + * }); + * ``` + */ +export async function cleanup(): Promise { + // Step 1: Try graceful stop via unraid-api + await remoteExecSafe('unraid-api stop 2>/dev/null; true'); + await sleep(1000); + + // Step 2: Check if processes remain + let count = await countUnraidApiProcesses(); + if (count === 0) { + await remoteExecSafe(`rm -f '${REMOTE_PID_PATH}' 2>/dev/null; true`); + return; + } + + // Step 3: Force kill - nodemon FIRST (prevents restart of child) + await remoteExecSafe("pkill -KILL -f 'nodemon.*nodemon.json' 2>/dev/null; true"); + await sleep(500); + + // Step 4: Force kill - then main.js children + await remoteExecSafe("pkill -KILL -f 'node.*dist/main.js' 2>/dev/null; true"); + await sleep(1000); + + // Step 5: Clean up PID file + await remoteExecSafe(`rm -f '${REMOTE_PID_PATH}' 2>/dev/null; true`); + + // Step 6: Verify - if still running, try harder with explicit PIDs + count = await countUnraidApiProcesses(); + if (count !== 0) { + const pidsResult = await remoteExecSafe( + "ps -eo pid,args | grep -E 'nodemon.*nodemon.json|node.*dist/main.js' | grep -v grep | awk '{print $1}'" + ); + const pids = pidsResult.stdout.trim().split('\n').filter(Boolean); + for (const pid of pids) { + await remoteExecSafe(`kill -9 ${pid} 2>/dev/null; true`); + } + await sleep(1000); + } + + // Final check + count = await countUnraidApiProcesses(); + if (count !== 0) { + const psResult = await remoteExecSafe( + "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" + ); + console.warn(`WARNING: Cleanup incomplete, remaining processes:\n${psResult.stdout}`); + } +} + +/** + * Starts the unraid-api daemon and waits for it to be ready. + * + * @throws {Error} If the start command fails or the API doesn't start within the timeout + * + * @example + * ```typescript + * await startApi(); + * // API is now running and ready + * await assertSingleApiInstance(); + * ``` + */ +export async function startApi(): Promise { + const result = await remoteExec('unraid-api start'); + if (result.exitCode !== 0) { + throw new Error(`Failed to start API: ${result.stderr}`); + } + const started = await waitForStart(); + if (!started) { + throw new Error('API did not start within timeout'); + } +} + +/** + * Stops the unraid-api daemon and waits for termination. + * + * @param force - If `true`, uses `--force` flag for immediate termination (SIGKILL). + * If `false` (default), uses graceful shutdown (SIGTERM). + * @throws {Error} If the stop command fails + * + * @example + * ```typescript + * // Graceful stop + * await stopApi(); + * + * // Force stop (immediate) + * await stopApi(true); + * ``` + */ +export async function stopApi(force = false): Promise { + const cmd = force ? 'unraid-api stop --force' : 'unraid-api stop'; + const result = await remoteExec(cmd); + if (result.exitCode !== 0) { + throw new Error(`Failed to stop API: ${result.stderr}`); + } + await waitForStop(); + await waitForAllProcessesStop(TEN_SECONDS); +} + +/** + * Retrieves the current status of the unraid-api daemon. + * + * @returns The status output from `unraid-api status` command + * + * @example + * ```typescript + * const status = await getStatus(); + * if (status.includes('running')) { + * console.log('API is active'); + * } else { + * console.log('API is stopped'); + * } + * ``` + */ +export async function getStatus(): Promise { + const result = await remoteExec('unraid-api status 2>&1'); + return result.stdout; +} diff --git a/tests/system-integration/src/helpers/process.ts b/tests/system-integration/src/helpers/process.ts new file mode 100644 index 0000000000..cb6116cf1d --- /dev/null +++ b/tests/system-integration/src/helpers/process.ts @@ -0,0 +1,199 @@ +/** + * @fileoverview Process query and assertion helpers for unraid-api daemon testing. + * Provides utilities to inspect and validate process state on a remote Unraid server. + * + * The unraid-api runs as a singleton daemon with two processes: + * - **nodemon**: Process supervisor that monitors and restarts the main process + * - **main.js**: The actual API server (Node.js application) + * + * @example + * ```typescript + * // Check if the API is running + * const pid = await getRemotePid(); + * if (pid && await isProcessRunning(pid)) { + * console.log('API is running with PID:', pid); + * } + * + * // Verify singleton enforcement + * await assertSingleApiInstance(); // Throws if not exactly 1 nodemon + 1 main.js + * ``` + */ + +import { remoteExec, remoteExecSafe } from './ssh.js'; + +/** + * Path to the PID file on the remote server. + * This file contains the PID of the nodemon supervisor process. + */ +export const REMOTE_PID_PATH = '/var/run/unraid-api/nodemon.pid'; + +/** + * Retrieves the PID from the remote PID file. + * + * @returns The PID as a string, or empty string if the file doesn't exist or is empty + * + * @example + * ```typescript + * const pid = await getRemotePid(); + * if (pid) { + * console.log('Found PID:', pid); + * } + * ``` + */ +export async function getRemotePid(): Promise { + const result = await remoteExec(`cat '${REMOTE_PID_PATH}' 2>/dev/null || true`); + return result.stdout.trim(); +} + +/** + * Checks if the PID file exists on the remote server. + * + * @returns `true` if the PID file exists, `false` otherwise + * + * @example + * ```typescript + * if (await pidFileExists()) { + * console.log('PID file exists'); + * } + * ``` + */ +export async function pidFileExists(): Promise { + const result = await remoteExec(`test -f '${REMOTE_PID_PATH}'`); + return result.exitCode === 0; +} + +/** + * Checks if a process with the given PID is currently running. + * Uses `kill -0` which checks process existence without sending a signal. + * + * @param pid - The process ID to check + * @returns `true` if the process is running, `false` if not running or PID is empty + * + * @example + * ```typescript + * const pid = await getRemotePid(); + * if (await isProcessRunning(pid)) { + * console.log('Process is alive'); + * } + * ``` + */ +export async function isProcessRunning(pid: string): Promise { + if (!pid) return false; + const result = await remoteExec(`kill -0 '${pid}' 2>/dev/null`); + return result.exitCode === 0; +} + +/** + * Counts the number of nodemon supervisor processes running. + * Looks for processes matching the pattern `nodemon.*nodemon.json`. + * + * @returns The number of nodemon processes (should be 0 or 1 in normal operation) + * + * @example + * ```typescript + * const count = await countNodemonProcesses(); + * expect(count).toBe(1); // Exactly one supervisor should run + * ``` + */ +export async function countNodemonProcesses(): Promise { + const result = await remoteExecSafe( + "ps -eo pid,args 2>/dev/null | grep -E 'nodemon.*nodemon.json' | grep -v grep | wc -l" + ); + const count = parseInt(result.stdout.trim(), 10); + return isNaN(count) ? 0 : count; +} + +/** + * Counts the number of main.js worker processes running. + * Looks for processes matching the pattern `node.*dist/main.js`. + * + * @returns The number of main.js processes (should be 0 or 1 in normal operation) + * + * @example + * ```typescript + * const count = await countMainProcesses(); + * expect(count).toBe(1); // Exactly one worker should run + * ``` + */ +export async function countMainProcesses(): Promise { + const result = await remoteExecSafe( + "ps -eo args 2>/dev/null | grep -E 'node.*dist/main\\.js' | grep -v grep | wc -l" + ); + const count = parseInt(result.stdout.trim(), 10); + return isNaN(count) ? 0 : count; +} + +/** + * Counts all unraid-api related processes (nodemon + main.js combined). + * + * @returns Total count of all API-related processes + * + * @example + * ```typescript + * const total = await countUnraidApiProcesses(); + * // Should be 2 when running (1 nodemon + 1 main.js) + * // Should be 0 when stopped + * ``` + */ +export async function countUnraidApiProcesses(): Promise { + const nodemonCount = await countNodemonProcesses(); + const mainCount = await countMainProcesses(); + return nodemonCount + mainCount; +} + +/** + * Asserts that exactly one nodemon and one main.js process are running. + * This validates proper singleton daemon enforcement. + * + * @throws {Error} If the process counts don't match expected values (1 each) + * + * @example + * ```typescript + * await startApi(); + * await assertSingleApiInstance(); // Passes if singleton is enforced + * ``` + */ +export async function assertSingleApiInstance(): Promise { + const nodemonCount = await countNodemonProcesses(); + const mainCount = await countMainProcesses(); + + if (nodemonCount !== 1) { + const psResult = await remoteExecSafe( + "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" + ); + throw new Error(`Expected 1 nodemon process, found ${nodemonCount}\n${psResult.stdout}`); + } + + if (mainCount !== 1) { + const psResult = await remoteExecSafe( + "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" + ); + throw new Error(`Expected 1 main.js process, found ${mainCount}\n${psResult.stdout}`); + } +} + +/** + * Asserts that no unraid-api processes are running. + * Used to verify clean shutdown. + * + * @throws {Error} If any nodemon or main.js processes are found + * + * @example + * ```typescript + * await stopApi(); + * await assertNoApiProcesses(); // Passes if all processes terminated + * ``` + */ +export async function assertNoApiProcesses(): Promise { + const nodemonCount = await countNodemonProcesses(); + const mainCount = await countMainProcesses(); + + if (nodemonCount !== 0 || mainCount !== 0) { + const psResult = await remoteExecSafe( + "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" + ); + throw new Error( + `Expected 0 processes, found nodemon=${nodemonCount} main.js=${mainCount}\n${psResult.stdout}` + ); + } +} diff --git a/tests/system-integration/src/helpers/server.ts b/tests/system-integration/src/helpers/server.ts new file mode 100644 index 0000000000..06d33aaf12 --- /dev/null +++ b/tests/system-integration/src/helpers/server.ts @@ -0,0 +1,63 @@ +/** + * @fileoverview Server management helpers for system-level operations. + * Provides utilities for rebooting and waiting for server availability. + */ + +import { remoteExecSafe, remoteExec } from './ssh.js'; +import { sleep, FIVE_SECONDS, FIVE_MINUTES, TEN_MINUTES } from './utils.js'; + +/** + * Reboots the remote server. + * Sends a reboot command via SSH and waits briefly for the connection to drop. + */ +export async function rebootServer(): Promise { + await remoteExecSafe('reboot'); +} + +/** + * Waits for the server to go offline after a reboot command. + * Polls SSH connectivity until the server stops responding or timeout is reached. + * + * @param timeout - Maximum time to wait in milliseconds (default: 5 minutes) + * @returns `true` if the server went offline within the timeout, `false` otherwise + */ +export async function waitForServerOffline(timeout = FIVE_MINUTES): Promise { + const deadline = Date.now() + timeout; + const pollInterval = FIVE_SECONDS; + + while (Date.now() < deadline) { + const result = await remoteExec('echo online'); + if (result.exitCode !== 0 || !result.stdout.includes('online')) { + return true; + } + await sleep(pollInterval); + } + + return false; +} + +/** + * Waits for the server to come back online after a reboot. + * Polls SSH connectivity until the server responds or timeout is reached. + * + * @param timeout - Maximum time to wait in milliseconds (default: 10 minutes) + * @returns `true` if the server is online within the timeout, `false` otherwise + */ +export async function waitForServerOnline(timeout = TEN_MINUTES): Promise { + const deadline = Date.now() + timeout; + const pollInterval = FIVE_SECONDS; + + while (Date.now() < deadline) { + try { + const result = await remoteExec('echo online'); + if (result.exitCode === 0 && result.stdout.includes('online')) { + return true; + } + } catch { + // SSH connection failed, server still rebooting + } + await sleep(pollInterval); + } + + return false; +} diff --git a/tests/system-integration/src/helpers/ssh.ts b/tests/system-integration/src/helpers/ssh.ts new file mode 100644 index 0000000000..d550895619 --- /dev/null +++ b/tests/system-integration/src/helpers/ssh.ts @@ -0,0 +1,130 @@ +/** + * @fileoverview SSH execution helpers for remote server testing. + * Provides utilities to execute commands on a remote Unraid server via SSH. + * + * @requires SERVER environment variable to be set with the target server hostname/IP + * + * @example + * ```typescript + * // Execute a command and check the result + * const result = await remoteExec('unraid-api status'); + * if (result.exitCode === 0) { + * console.log(result.stdout); + * } + * + * // Execute a cleanup command, ignoring failures + * await remoteExecSafe('rm -f /tmp/test-file'); + * ``` + */ + +import { execa } from 'execa'; + +/** + * Result of a remote command execution. + */ +export interface ExecResult { + /** Standard output from the command */ + stdout: string; + /** Standard error from the command */ + stderr: string; + /** Exit code of the command (0 indicates success) */ + exitCode: number; +} + +/** + * Retrieves the target server from the SERVER environment variable. + * @throws {Error} If SERVER environment variable is not set + * @returns The server hostname or IP address + */ +function getServer(): string { + const server = process.env.SERVER; + if (!server) { + throw new Error('SERVER environment variable must be set'); + } + return server; +} + +/** + * SSH connection options used for all remote executions. + * - ConnectTimeout: 10 seconds + * - BatchMode: Disables password prompts (requires key-based auth) + * - StrictHostKeyChecking: Automatically accepts new host keys + */ +const SSH_OPTIONS = [ + '-o', + 'ConnectTimeout=10', + '-o', + 'BatchMode=yes', + '-o', + 'StrictHostKeyChecking=accept-new', +]; + +/** + * Executes a command on the remote server via SSH. + * + * @param cmd - The shell command to execute on the remote server + * @returns Promise resolving to the execution result with stdout, stderr, and exit code + * + * @example + * ```typescript + * const result = await remoteExec('unraid-api start'); + * if (result.exitCode !== 0) { + * throw new Error(`Failed: ${result.stderr}`); + * } + * ``` + */ +export async function remoteExec(cmd: string): Promise { + const server = getServer(); + const result = await execa('ssh', [...SSH_OPTIONS, `root@${server}`, cmd], { + reject: false, + }); + + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode ?? 0, + }; +} + +/** + * Executes a command on the remote server, suppressing any errors. + * Useful for cleanup operations where failures should be ignored. + * + * @param cmd - The shell command to execute on the remote server + * @returns Promise resolving to the execution result, or empty result on error + * + * @example + * ```typescript + * // Remove a file, ignoring if it doesn't exist + * await remoteExecSafe('rm -f /var/run/unraid-api/nodemon.pid'); + * ``` + */ +export async function remoteExecSafe(cmd: string): Promise { + const server = getServer(); + try { + const result = await execa('ssh', [...SSH_OPTIONS, `root@${server}`, cmd], { + reject: false, + }); + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode ?? 0, + }; + } catch { + return { + stdout: '', + stderr: '', + exitCode: 0, + }; + } +} + +/** + * Returns the configured server name from the SERVER environment variable. + * + * @throws {Error} If SERVER environment variable is not set + * @returns The server hostname or IP address + */ +export function getServerName(): string { + return getServer(); +} diff --git a/tests/system-integration/src/helpers/utils.ts b/tests/system-integration/src/helpers/utils.ts new file mode 100644 index 0000000000..8572efb5af --- /dev/null +++ b/tests/system-integration/src/helpers/utils.ts @@ -0,0 +1,19 @@ +/** + * @fileoverview Shared utility functions for system integration tests. + */ + +export const ONE_SECOND = 1000; +export const FIVE_SECONDS = 5 * ONE_SECOND; +export const TEN_SECONDS = 10 * ONE_SECOND; +export const ONE_MINUTE = 60 * ONE_SECOND; +export const FIVE_MINUTES = 5 * ONE_MINUTE; +export const TEN_MINUTES = 10 * ONE_MINUTE; +export const FIFTEEN_MINUTES = 15 * ONE_MINUTE; + +/** + * Utility function to pause execution. + * @param ms - Duration to sleep in milliseconds + */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/tests/system-integration/src/tests/singleton-daemon.test.ts b/tests/system-integration/src/tests/singleton-daemon.test.ts new file mode 100644 index 0000000000..aca7907372 --- /dev/null +++ b/tests/system-integration/src/tests/singleton-daemon.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { remoteExec } from '../helpers/ssh.js'; +import { + getRemotePid, + pidFileExists, + isProcessRunning, + countNodemonProcesses, + assertSingleApiInstance, + assertNoApiProcesses, + REMOTE_PID_PATH, +} from '../helpers/process.js'; +import { cleanup, startApi, stopApi, getStatus, waitForStart } from '../helpers/api-lifecycle.js'; +import { rebootServer, waitForServerOffline, waitForServerOnline } from '../helpers/server.js'; +import { TEN_SECONDS, ONE_MINUTE, FIFTEEN_MINUTES } from '../helpers/utils.js'; + +describe('singleton daemon', () => { + beforeAll(async () => { + if (!process.env.SERVER) { + throw new Error('SERVER environment variable must be set'); + } + }); + + afterAll(async () => { + await cleanup(); + await startApi(); + }); + + beforeEach(async () => { + await cleanup(); + }); + + describe('start command', () => { + it('creates a single process with PID file', async () => { + await startApi(); + + expect(await pidFileExists()).toBe(true); + + const pid = await getRemotePid(); + expect(pid).toBeTruthy(); + expect(pid).toMatch(/^\d+$/); + + expect(await isProcessRunning(pid)).toBe(true); + + await assertSingleApiInstance(); + }); + + it('second start does not create duplicate process', async () => { + await startApi(); + + const initialPid = await getRemotePid(); + expect(initialPid).toBeTruthy(); + + await assertSingleApiInstance(); + + await remoteExec('unraid-api start'); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + await assertSingleApiInstance(); + + expect(await pidFileExists()).toBe(true); + + const finalPid = await getRemotePid(); + expect(finalPid).toBeTruthy(); + + expect(await isProcessRunning(finalPid)).toBe(true); + }); + + it('cleans up stale PID file', async () => { + await remoteExec(`mkdir -p /var/run/unraid-api && echo '99999' > '${REMOTE_PID_PATH}'`); + + await startApi(); + + const pid = await getRemotePid(); + expect(pid).toBeTruthy(); + expect(pid).not.toBe('99999'); + + expect(await isProcessRunning(pid)).toBe(true); + }); + + it('cleans up orphaned nodemon process', async () => { + await startApi(); + + await remoteExec(`rm -f '${REMOTE_PID_PATH}'`); + + const count = await countNodemonProcesses(); + expect(count).toBe(1); + + await startApi(); + + const newCount = await countNodemonProcesses(); + expect(newCount).toBe(1); + + expect(await pidFileExists()).toBe(true); + }); + }); + + describe('status command', () => { + it('reports running when API is active', async () => { + await startApi(); + + const output = await getStatus(); + expect(output).toMatch(/running/i); + }); + + it('reports not running when API is stopped', async () => { + const output = await getStatus(); + expect(output).toMatch(/not running/i); + }); + }); + + describe('stop command', () => { + it('cleanly terminates all processes', async () => { + await startApi(); + + const pid = await getRemotePid(); + expect(pid).toBeTruthy(); + + await assertSingleApiInstance(); + + await stopApi(); + + expect(await pidFileExists()).toBe(false); + + await assertNoApiProcesses(); + }); + + it('stop --force terminates all processes immediately', async () => { + await startApi(); + + const pid = await getRemotePid(); + expect(pid).toBeTruthy(); + + await assertSingleApiInstance(); + + await stopApi(true); + + expect(await pidFileExists()).toBe(false); + + await assertNoApiProcesses(); + }); + }); + + describe('restart command', () => { + it('creates new process when already running', async () => { + await startApi(); + + const initialPid = await getRemotePid(); + expect(initialPid).toBeTruthy(); + + await assertSingleApiInstance(); + + await remoteExec('unraid-api restart'); + + await new Promise((resolve) => setTimeout(resolve, 3000)); + await waitForStart(TEN_SECONDS); + + const newPid = await getRemotePid(); + expect(newPid).toBeTruthy(); + + expect(initialPid).not.toBe(newPid); + + await assertSingleApiInstance(); + }); + + it('works when API is not running', async () => { + await remoteExec('unraid-api restart'); + + await waitForStart(TEN_SECONDS); + + const pid = await getRemotePid(); + expect(pid).toBeTruthy(); + + expect(await isProcessRunning(pid)).toBe(true); + }); + }); + + describe('edge cases', () => { + it('concurrent starts result in single process', async () => { + await remoteExec('unraid-api start & unraid-api start & wait'); + + await new Promise((resolve) => setTimeout(resolve, 3000)); + + await assertSingleApiInstance(); + + expect(await pidFileExists()).toBe(true); + }); + + it('API recovers after process is killed externally', async () => { + await startApi(); + + const pid = await getRemotePid(); + expect(pid).toBeTruthy(); + + await remoteExec(`kill -9 '${pid}'`); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + await startApi(); + + const newPid = await getRemotePid(); + expect(newPid).toBeTruthy(); + + expect(await isProcessRunning(newPid)).toBe(true); + }); + }); + + describe('server reboot', () => { + it( + 'API starts automatically after server reboot', + async () => { + await startApi(); + await assertSingleApiInstance(); + + await rebootServer(); + + const offline = await waitForServerOffline(); + expect(offline).toBe(true); + + const online = await waitForServerOnline(); + expect(online).toBe(true); + + const started = await waitForStart(ONE_MINUTE); + expect(started).toBe(true); + + await assertSingleApiInstance(); + + const status = await getStatus(); + expect(status).toMatch(/running/i); + }, + FIFTEEN_MINUTES + ); + }); +}); diff --git a/tests/system-integration/tsconfig.json b/tests/system-integration/tsconfig.json new file mode 100644 index 0000000000..e8ba7ba763 --- /dev/null +++ b/tests/system-integration/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "outDir": "./dist", + "rootDir": "./src", + "types": ["vitest/globals"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/tests/system-integration/vitest.config.ts b/tests/system-integration/vitest.config.ts new file mode 100644 index 0000000000..40cd01db64 --- /dev/null +++ b/tests/system-integration/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + include: ['src/tests/**/*.test.ts'], + testTimeout: 60000, + hookTimeout: 60000, + sequence: { + concurrent: false, + }, + pool: 'forks', + poolOptions: { + forks: { + singleFork: true, + }, + }, + }, +}); diff --git a/web/__test__/components/UserProfile.test.ts b/web/__test__/components/UserProfile.test.ts index ef4d2a53e6..f252d000e9 100644 --- a/web/__test__/components/UserProfile.test.ts +++ b/web/__test__/components/UserProfile.test.ts @@ -358,8 +358,53 @@ describe('UserProfile.standalone.vue', () => { expect(wrapper.find('[data-testid="notifications-sidebar"]').exists()).toBe(true); }); - it('conditionally renders banner based on theme store', async () => { - const bannerSelector = 'div.absolute.z-0'; + it('renders banner gradient when CSS variable is set (even if theme store has banner disabled)', async () => { + const gradientValue = 'linear-gradient(to right, #111111, #222222)'; + document.documentElement.style.setProperty('--banner-gradient', gradientValue); + + const localPinia = createTestingPinia({ + createSpy: vi.fn, + initialState: { + server: { ...initialServerData }, + theme: { + theme: { + name: 'white', + banner: false, + bannerGradient: false, + descriptionShow: true, + textColor: '', + metaColor: '', + bgColor: '', + }, + }, + }, + stubActions: false, + }); + setActivePinia(localPinia); + + const localWrapper = mount(UserProfile, { + props: { + server: JSON.stringify(initialServerData), + }, + global: { + plugins: [localPinia], + stubs, + }, + }); + + await localWrapper.vm.$nextTick(); + + const bannerEl = localWrapper.find('div.absolute.z-0'); + expect(bannerEl.exists()).toBe(true); + expect(bannerEl.attributes('style')).toContain(gradientValue); + + localWrapper.unmount(); + document.documentElement.style.removeProperty('--banner-gradient'); + setActivePinia(pinia); + }); + + it('does not render banner gradient when CSS variable is absent, regardless of theme store flags', async () => { + document.documentElement.style.removeProperty('--banner-gradient'); themeStore.theme = { ...themeStore.theme!, @@ -368,19 +413,7 @@ describe('UserProfile.standalone.vue', () => { }; await wrapper.vm.$nextTick(); - expect(themeStore.bannerGradient).toContain('background-image: linear-gradient'); - expect(wrapper.find(bannerSelector).exists()).toBe(true); - - themeStore.theme!.bannerGradient = false; - await wrapper.vm.$nextTick(); - - expect(themeStore.bannerGradient).toBeUndefined(); - expect(wrapper.find(bannerSelector).exists()).toBe(false); - - themeStore.theme!.bannerGradient = true; - await wrapper.vm.$nextTick(); - - expect(themeStore.bannerGradient).toContain('background-image: linear-gradient'); - expect(wrapper.find(bannerSelector).exists()).toBe(true); + const bannerEl = wrapper.find('div.absolute.z-0'); + expect(bannerEl.exists()).toBe(false); }); }); diff --git a/web/composables/gql/graphql.ts b/web/composables/gql/graphql.ts index a6171b6772..8335211509 100644 --- a/web/composables/gql/graphql.ts +++ b/web/composables/gql/graphql.ts @@ -1523,8 +1523,8 @@ export type PackageVersions = { openssl?: Maybe; /** PHP version */ php?: Maybe; - /** pm2 version */ - pm2?: Maybe; + /** nodemon version */ + nodemon?: Maybe; }; export type ParityCheck = { diff --git a/web/src/components/UserProfile.standalone.vue b/web/src/components/UserProfile.standalone.vue index 609ab906cd..7337c9e71a 100644 --- a/web/src/components/UserProfile.standalone.vue +++ b/web/src/components/UserProfile.standalone.vue @@ -35,7 +35,18 @@ const description = computed(() => serverStore.description); const guid = computed(() => serverStore.guid); const keyfile = computed(() => serverStore.keyfile); const lanIp = computed(() => serverStore.lanIp); -const bannerGradient = computed(() => themeStore.bannerGradient); +const bannerGradient = ref(); + +const loadBannerGradientFromCss = () => { + if (typeof window === 'undefined') return; + + const rawGradient = getComputedStyle(document.documentElement) + .getPropertyValue('--banner-gradient') + .trim(); + + bannerGradient.value = rawGradient ? `background-image: ${rawGradient};` : undefined; +}; + const theme = computed(() => themeStore.theme); // Control dropdown open state @@ -85,6 +96,8 @@ onBeforeMount(() => { }); onMounted(() => { + loadBannerGradientFromCss(); + if (devConfig.VITE_MOCK_USER_SESSION && devConfig.NODE_ENV === 'development') { document.cookie = 'unraid_session_cookie=mockusersession'; } diff --git a/web/src/composables/gql/graphql.ts b/web/src/composables/gql/graphql.ts index e683aa0c02..a44fe13e7c 100644 --- a/web/src/composables/gql/graphql.ts +++ b/web/src/composables/gql/graphql.ts @@ -559,6 +559,17 @@ export type CpuLoad = { percentUser: Scalars['Float']['output']; }; +export type CpuPackages = Node & { + __typename?: 'CpuPackages'; + id: Scalars['PrefixedID']['output']; + /** Power draw per package (W) */ + power: Array; + /** Temperature per package (°C) */ + temp: Array; + /** Total CPU package power draw (W) */ + totalPower: Scalars['Float']['output']; +}; + export type CpuUtilization = Node & { __typename?: 'CpuUtilization'; /** CPU load for each core */ @@ -869,6 +880,7 @@ export type InfoCpu = Node & { manufacturer?: Maybe; /** CPU model */ model?: Maybe; + packages: CpuPackages; /** Number of physical processors */ processors?: Maybe; /** CPU revision */ @@ -885,6 +897,8 @@ export type InfoCpu = Node & { stepping?: Maybe; /** Number of CPU threads */ threads?: Maybe; + /** Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]] */ + topology: Array>>; /** CPU vendor */ vendor?: Maybe; /** CPU voltage */ @@ -1531,14 +1545,14 @@ export type PackageVersions = { nginx?: Maybe; /** Node.js version */ node?: Maybe; + /** nodemon version */ + nodemon?: Maybe; /** npm version */ npm?: Maybe; /** OpenSSL version */ openssl?: Maybe; /** PHP version */ php?: Maybe; - /** pm2 version */ - pm2?: Maybe; }; export type ParityCheck = { @@ -2053,6 +2067,7 @@ export type Subscription = { parityHistorySubscription: ParityCheck; serversSubscription: Server; systemMetricsCpu: CpuUtilization; + systemMetricsCpuTelemetry: CpuPackages; systemMetricsMemory: MemoryUtilization; upsUpdates: UpsDevice; }; diff --git a/web/src/composables/gql/index.ts b/web/src/composables/gql/index.ts index c682b1e2f9..0ea4a91cf8 100644 --- a/web/src/composables/gql/index.ts +++ b/web/src/composables/gql/index.ts @@ -1,2 +1,2 @@ -export * from './fragment-masking'; -export * from './gql'; +export * from "./fragment-masking"; +export * from "./gql";