feat!(api): swap daemonizer to nodemon instead of PM2 (#1798)

## Summary
- ensure the API release build copies nodemon.json into the packaged
artifacts so nodemon-managed deployments have the config available

## Testing
- pnpm --filter @unraid/api lint:fix

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_691e1f4bde3483238726478f6fb2d52a)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Switch to Nodemon for process management and updated CLI to use it.
  * Added boot-time diagnostic logging and direct log-file writing.
  * New per-package CPU telemetry and topology exposure.

* **Bug Fixes**
  * More reliable process health detection and lifecycle handling.
  * Improved log handling and startup robustness.

* **Chores**
  * Removed PM2-related components and tests; migrated to Nodemon.
  * Consolidated pub/sub channel usage and bumped internal version.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Pujit Mehrotra <pujit@lime-technology.com>
This commit is contained in:
Eli Bosley
2025-12-11 15:42:05 -05:00
committed by GitHub
parent e35bcc72f1
commit 6f54206a4a
61 changed files with 1800 additions and 1383 deletions

View File

@@ -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
}
]
}

View File

@@ -1673,8 +1673,8 @@ type PackageVersions {
"""npm version"""
npm: String
"""pm2 version"""
pm2: String
"""nodemon version"""
nodemon: String
"""Git version"""
git: String

View File

@@ -1257,7 +1257,7 @@ type Versions {
openssl: String
perl: String
php: String
pm2: String
nodemon: String
postfix: String
postgresql: String
python: String

17
api/nodemon.json Normal file
View File

@@ -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"
}
}

View File

@@ -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",

View File

@@ -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');

View File

@@ -1,5 +0,0 @@
/* eslint-disable no-undef */
// Dummy process for PM2 testing
setInterval(() => {
// Keep process alive
}, 1000);

View File

@@ -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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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
});
});

View File

@@ -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<typeof import('@app/environment.js')>('@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);
});
});

View File

@@ -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'));
});
});

View File

@@ -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,
},

View File

@@ -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);
}

View File

@@ -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',

View File

@@ -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 });
/**

View File

@@ -1,40 +0,0 @@
export const isUnraidApiRunning = async (): Promise<boolean | undefined> => {
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<boolean>((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<boolean>((resolve) => {
setTimeout(() => resolve(false), 10000); // 10 second timeout
});
return Promise.race([pm2Promise, timeoutPromise]);
};

View File

@@ -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<boolean> => {
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;
}
};

View File

@@ -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';

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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 {}

View File

@@ -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,

View File

@@ -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<Scalars['Float']['output']>;
/** Temperature per package (°C) */
temp: Array<Scalars['Float']['output']>;
/** 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<Scalars['String']['output']>;
/** CPU model */
model?: Maybe<Scalars['String']['output']>;
packages: CpuPackages;
/** Number of physical processors */
processors?: Maybe<Scalars['Int']['output']>;
/** CPU revision */
@@ -885,6 +897,8 @@ export type InfoCpu = Node & {
stepping?: Maybe<Scalars['Int']['output']>;
/** Number of CPU threads */
threads?: Maybe<Scalars['Int']['output']>;
/** Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]] */
topology: Array<Array<Array<Scalars['Int']['output']>>>;
/** CPU vendor */
vendor?: Maybe<Scalars['String']['output']>;
/** CPU voltage */
@@ -1531,14 +1545,14 @@ export type PackageVersions = {
nginx?: Maybe<Scalars['String']['output']>;
/** Node.js version */
node?: Maybe<Scalars['String']['output']>;
/** nodemon version */
nodemon?: Maybe<Scalars['String']['output']>;
/** npm version */
npm?: Maybe<Scalars['String']['output']>;
/** OpenSSL version */
openssl?: Maybe<Scalars['String']['output']>;
/** PHP version */
php?: Maybe<Scalars['String']['output']>;
/** pm2 version */
pm2?: Maybe<Scalars['String']['output']>;
};
export type ParityCheck = {
@@ -2053,6 +2067,7 @@ export type Subscription = {
parityHistorySubscription: ParityCheck;
serversSubscription: Server;
systemMetricsCpu: CpuUtilization;
systemMetricsCpuTelemetry: CpuPackages;
systemMetricsMemory: MemoryUtilization;
upsUpdates: UpsDevice;
};

View File

@@ -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<void> {
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);
}
}

View File

@@ -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));
}
}

View File

@@ -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<string, Array<(...args: any[]) => 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<typeof createWriteStream> & {
emit: (event: string, ...args: any[]) => void;
};
};
const createSpawnMock = (pid?: number) => {
const unref = vi.fn();
return {
pid,
unref,
} as unknown as ReturnType<typeof spawn>;
};
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<typeof fs>();
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<void> },
'stopPm2IfRunning'
);
const findMatchingSpy = vi.spyOn(
NodemonService.prototype as unknown as { findMatchingNodemonPids: () => Promise<number[]> },
'findMatchingNodemonPids'
);
const findDirectMainSpy = vi.spyOn(
NodemonService.prototype as unknown as { findDirectMainPids: () => Promise<number[]> },
'findDirectMainPids'
);
const terminateSpy = vi.spyOn(
NodemonService.prototype as unknown as { terminatePids: (pids: number[]) => Promise<void> },
'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<void> },
'waitForNodemonExit'
).mockResolvedValue();
vi.spyOn(
service as unknown as { getStoredPid: () => Promise<number | null> },
'getStoredPid'
).mockResolvedValue(999);
vi.spyOn(
service as unknown as { isPidRunning: (pid: number) => Promise<boolean> },
'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<number | null> },
'getStoredPid'
).mockResolvedValue(555);
vi.spyOn(
service as unknown as { isPidRunning: (pid: number) => Promise<boolean> },
'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<boolean> },
'isPidRunning'
).mockResolvedValue(true);
vi.spyOn(
service as unknown as { waitForNodemonExit: () => Promise<void> },
'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<number[]>;
},
'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<number[]>;
},
'waitForPidsToExit'
).mockResolvedValue([100, 200]);
const terminatePidsWithForceSpy = vi
.spyOn(
service as unknown as {
terminatePidsWithForce: (pids: number[], gracePeriodMs?: number) => Promise<void>;
},
'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<void>;
},
'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<number[]>;
},
'waitForPidsToExit'
)
.mockResolvedValue([100]);
vi.spyOn(
service as unknown as {
terminatePidsWithForce: (pids: number[], gracePeriodMs?: number) => Promise<void>;
},
'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<ReturnType<typeof execa>>);
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<void> },
'waitForNodemonExit'
)
.mockResolvedValue();
vi.spyOn(
service as unknown as { getStoredPid: () => Promise<number | null> },
'getStoredPid'
).mockResolvedValue(123);
vi.spyOn(
service as unknown as { isPidRunning: (pid: number) => Promise<boolean> },
'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<void> },
'waitForNodemonExit'
)
.mockResolvedValue();
vi.spyOn(
service as unknown as { getStoredPid: () => Promise<number | null> },
'getStoredPid'
).mockResolvedValue(null);
await service.restart();
expect(stopSpy).not.toHaveBeenCalled();
expect(waitSpy).not.toHaveBeenCalled();
expect(startSpy).toHaveBeenCalled();
});
});

View File

@@ -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<string, string | undefined>;
};
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<void> {
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<T>(fn: () => Promise<T>): Promise<T> {
let release: (() => Promise<void>) | 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<number | null> {
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<boolean> {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
private async findMatchingNodemonPids(): Promise<number[]> {
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<number[]> {
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<number[]> {
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<void> {
// 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<string, string>;
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<boolean> {
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<string> {
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 '';
}
}
}

View File

@@ -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);
});
});
});

View File

@@ -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\<Result\> When raw is false
*/
run<T extends CmdContext>(context: T & { raw: true }, ...args: string[]): ResultPromise<T>;
run(context: CmdContext & { raw?: false }, ...args: string[]): Promise<Result>;
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.`
);
}
}
}

View File

@@ -33,9 +33,9 @@ export class ReportCommand extends CommandRunner {
async report(): Promise<string | void> {
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;
});

View File

@@ -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<void> {
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);

View File

@@ -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<void> {
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({

View File

@@ -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<void> {
await this.pm2.run(
{ tag: 'PM2 Status', stdio: 'inherit', raw: true },
'status',
'unraid-api',
'--mini-list'
);
await this.nodemon.status();
}
}

View File

@@ -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 });
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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');
});
});

View File

@@ -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);
}
}

View File

@@ -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 () => {

View File

@@ -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}`);
}
}

View File

@@ -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 },
},

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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>(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>(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>(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);
});
});
});

View File

@@ -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
);
}
}

View File

@@ -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',
},

View File

@@ -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);
}
}

View File

@@ -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,
});
}

View File

@@ -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);
}
}

View File

@@ -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 {

View File

@@ -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);
});
});
});

View File

@@ -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<T = any>(topic: PUBSUB_CHANNEL | string): AsyncIterableIterator<T> {
public createTrackedSubscription<T = any>(
topic: GRAPHQL_PUBSUB_CHANNEL | string
): AsyncIterableIterator<T> {
const innerIterator = createSubscription<T>(topic);
// Subscribe when the subscription starts

View File

@@ -140,16 +140,6 @@ export async function bootstrapNestServer(): Promise<NestFastifyApplication> {
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;

View File

@@ -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

625
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -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);
});
});

View File

@@ -1523,8 +1523,8 @@ export type PackageVersions = {
openssl?: Maybe<Scalars['String']['output']>;
/** PHP version */
php?: Maybe<Scalars['String']['output']>;
/** pm2 version */
pm2?: Maybe<Scalars['String']['output']>;
/** nodemon version */
nodemon?: Maybe<Scalars['String']['output']>;
};
export type ParityCheck = {

View File

@@ -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<string>();
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';
}

View File

@@ -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<Scalars['Float']['output']>;
/** Temperature per package (°C) */
temp: Array<Scalars['Float']['output']>;
/** 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<Scalars['String']['output']>;
/** CPU model */
model?: Maybe<Scalars['String']['output']>;
packages: CpuPackages;
/** Number of physical processors */
processors?: Maybe<Scalars['Int']['output']>;
/** CPU revision */
@@ -885,6 +897,8 @@ export type InfoCpu = Node & {
stepping?: Maybe<Scalars['Int']['output']>;
/** Number of CPU threads */
threads?: Maybe<Scalars['Int']['output']>;
/** Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]] */
topology: Array<Array<Array<Scalars['Int']['output']>>>;
/** CPU vendor */
vendor?: Maybe<Scalars['String']['output']>;
/** CPU voltage */
@@ -1531,14 +1545,14 @@ export type PackageVersions = {
nginx?: Maybe<Scalars['String']['output']>;
/** Node.js version */
node?: Maybe<Scalars['String']['output']>;
/** nodemon version */
nodemon?: Maybe<Scalars['String']['output']>;
/** npm version */
npm?: Maybe<Scalars['String']['output']>;
/** OpenSSL version */
openssl?: Maybe<Scalars['String']['output']>;
/** PHP version */
php?: Maybe<Scalars['String']['output']>;
/** pm2 version */
pm2?: Maybe<Scalars['String']['output']>;
};
export type ParityCheck = {
@@ -2053,6 +2067,7 @@ export type Subscription = {
parityHistorySubscription: ParityCheck;
serversSubscription: Server;
systemMetricsCpu: CpuUtilization;
systemMetricsCpuTelemetry: CpuPackages;
systemMetricsMemory: MemoryUtilization;
upsUpdates: UpsDevice;
};

View File

@@ -1,2 +1,2 @@
export * from './fragment-masking';
export * from './gql';
export * from "./fragment-masking";
export * from "./gql";