mirror of
https://github.com/unraid/api.git
synced 2026-01-06 08:39:54 -06:00
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:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1673,8 +1673,8 @@ type PackageVersions {
|
||||
"""npm version"""
|
||||
npm: String
|
||||
|
||||
"""pm2 version"""
|
||||
pm2: String
|
||||
"""nodemon version"""
|
||||
nodemon: String
|
||||
|
||||
"""Git version"""
|
||||
git: String
|
||||
|
||||
@@ -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
17
api/nodemon.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
/* eslint-disable no-undef */
|
||||
// Dummy process for PM2 testing
|
||||
setInterval(() => {
|
||||
// Keep process alive
|
||||
}, 1000);
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
29
api/src/__test__/environment.nodemon-paths.test.ts
Normal file
29
api/src/__test__/environment.nodemon-paths.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
|
||||
/**
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
23
api/src/core/utils/process/unraid-api-running.ts
Normal file
23
api/src/core/utils/process/unraid-api-running.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
142
api/src/unraid-api/cli/nodemon.service.integration.spec.ts
Normal file
142
api/src/unraid-api/cli/nodemon.service.integration.spec.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
569
api/src/unraid-api/cli/nodemon.service.spec.ts
Normal file
569
api/src/unraid-api/cli/nodemon.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
534
api/src/unraid-api/cli/nodemon.service.ts
Normal file
534
api/src/unraid-api/cli/nodemon.service.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
625
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './fragment-masking';
|
||||
export * from './gql';
|
||||
export * from "./fragment-masking";
|
||||
export * from "./gql";
|
||||
|
||||
Reference in New Issue
Block a user