feat(cli): Add stats CLI command and daemon stats aggregation; fix process manager & wrapper state handling
This commit is contained in:
10
changelog.md
10
changelog.md
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-08-31 - 5.7.0 - feat(cli)
|
||||
Add 'stats' CLI command and daemon stats aggregation; fix process manager & wrapper state handling
|
||||
|
||||
- Add new 'stats' CLI command to show daemon + process statistics (memory, CPU, uptime, logs in memory, paths, configs) and include it in the default help output
|
||||
- Implement daemon-side aggregation for logs-in-memory, per-process log counts/bytes, and expose tspmDir/socket/pidFile and config counts in daemon:status
|
||||
- Enhance startById handler to detect already-running monitors and return current status/pid instead of attempting to restart
|
||||
- Improve ProcessManager start/restart/stop behavior: if an existing monitor exists but is not running, restart it; ensure PID and status are updated consistently (clear PID on stop)
|
||||
- Fix ProcessWrapper lifecycle handling: clear internal process reference on exit, improve isRunning() and getPid() semantics to reflect actual runtime state
|
||||
- Update IPC types to include optional metadata fields (paths, configs, logsInMemory) in DaemonStatusResponse
|
||||
|
||||
## 2025-08-31 - 5.6.2 - fix(processmanager)
|
||||
Improve process lifecycle handling and cleanup in daemon, monitors and wrappers
|
||||
|
||||
|
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@git.zone/tspm',
|
||||
version: '5.6.2',
|
||||
version: '5.7.0',
|
||||
description: 'a no fuzz process manager'
|
||||
}
|
||||
|
@@ -39,6 +39,7 @@ export function registerDefaultCommand(smartcli: plugins.smartcli.Smartcli) {
|
||||
);
|
||||
console.log(' daemon stop Stop the daemon');
|
||||
console.log(' daemon status Show daemon status');
|
||||
console.log(' stats Show daemon + process stats');
|
||||
console.log(
|
||||
'\nUse tspm [command] --help for more information about a command.',
|
||||
);
|
||||
|
66
ts/cli/commands/stats.ts
Normal file
66
ts/cli/commands/stats.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { tspmIpcClient } from '../../client/tspm.ipcclient.js';
|
||||
import type { CliArguments } from '../types.js';
|
||||
import { registerIpcCommand } from '../registration/index.js';
|
||||
import { pad } from '../helpers/formatting.js';
|
||||
import { formatMemory } from '../helpers/memory.js';
|
||||
|
||||
export function registerStatsCommand(smartcli: plugins.smartcli.Smartcli) {
|
||||
registerIpcCommand(
|
||||
smartcli,
|
||||
'stats',
|
||||
async (_argvArg: CliArguments) => {
|
||||
// Daemon status
|
||||
const status = await tspmIpcClient.request('daemon:status', {});
|
||||
|
||||
console.log('TSPM Daemon:');
|
||||
console.log('─'.repeat(60));
|
||||
console.log(`Version: ${status.version || 'unknown'}`);
|
||||
console.log(`PID: ${status.pid}`);
|
||||
console.log(`Uptime: ${Math.floor((status.uptime || 0) / 1000)}s`);
|
||||
console.log(`Processes: ${status.processCount}`);
|
||||
if (typeof status.memoryUsage === 'number') {
|
||||
console.log(`Memory: ${formatMemory(status.memoryUsage)}`);
|
||||
}
|
||||
if (typeof status.cpuUsage === 'number') {
|
||||
console.log(`CPU (user): ${status.cpuUsage.toFixed(3)}s`);
|
||||
}
|
||||
if ((status as any).paths) {
|
||||
const pathsInfo = (status as any).paths as { tspmDir?: string; socketPath?: string; pidFile?: string };
|
||||
console.log(`tspmDir: ${pathsInfo.tspmDir || '-'}`);
|
||||
console.log(`Socket: ${pathsInfo.socketPath || '-'}`);
|
||||
console.log(`PID File: ${pathsInfo.pidFile || '-'}`);
|
||||
}
|
||||
if ((status as any).configs) {
|
||||
const cfg = (status as any).configs as { processConfigs?: number };
|
||||
console.log(`Configs: ${cfg.processConfigs ?? 0}`);
|
||||
}
|
||||
if ((status as any).logsInMemory) {
|
||||
const lm = (status as any).logsInMemory as { totalCount: number; totalBytes: number };
|
||||
console.log(`Logs (mem): ${lm.totalCount} entries, ${formatMemory(lm.totalBytes)}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Process list (reuse list view with CPU column)
|
||||
const response = await tspmIpcClient.request('list', {});
|
||||
const processes = response.processes;
|
||||
console.log('Process List:');
|
||||
console.log('┌─────────┬─────────────┬───────────┬───────────┬──────────┬──────────┬─────────┐');
|
||||
console.log('│ ID │ Name │ Status │ PID │ Memory │ CPU │ Restarts │');
|
||||
console.log('├─────────┼─────────────┼───────────┼───────────┼──────────┼──────────┼──────────┤');
|
||||
for (const proc of processes) {
|
||||
const statusColor =
|
||||
proc.status === 'online' ? '\x1b[32m' : proc.status === 'errored' ? '\x1b[31m' : '\x1b[33m';
|
||||
const resetColor = '\x1b[0m';
|
||||
const cpuStr = typeof proc.cpu === 'number' && isFinite(proc.cpu) ? `${proc.cpu.toFixed(1)}%` : '-';
|
||||
const nameDisplay = String(proc.id); // name not carried in IProcessInfo
|
||||
console.log(
|
||||
`│ ${pad(String(proc.id), 7)} │ ${pad(nameDisplay, 11)} │ ${statusColor}${pad(proc.status, 9)}${resetColor} │ ${pad((proc.pid || '-').toString(), 9)} │ ${pad(formatMemory(proc.memory), 8)} │ ${pad(cpuStr, 8)} │ ${pad(proc.restarts.toString(), 8)} │`,
|
||||
);
|
||||
}
|
||||
console.log('└─────────┴─────────────┴───────────┴───────────┴──────────┴──────────┴──────────┘');
|
||||
},
|
||||
{ actionLabel: 'get daemon stats' },
|
||||
);
|
||||
}
|
||||
|
@@ -20,6 +20,7 @@ import { registerStartAllCommand } from './commands/batch/start-all.js';
|
||||
import { registerStopAllCommand } from './commands/batch/stop-all.js';
|
||||
import { registerRestartAllCommand } from './commands/batch/restart-all.js';
|
||||
import { registerDaemonCommand } from './commands/daemon/index.js';
|
||||
import { registerStatsCommand } from './commands/stats.js';
|
||||
import { registerEnableCommand } from './commands/service/enable.js';
|
||||
import { registerDisableCommand } from './commands/service/disable.js';
|
||||
import { registerResetCommand } from './commands/reset.js';
|
||||
@@ -117,6 +118,7 @@ export const run = async (): Promise<void> => {
|
||||
|
||||
// Daemon commands
|
||||
registerDaemonCommand(smartcliInstance);
|
||||
registerStatsCommand(smartcliInstance);
|
||||
|
||||
// Service commands
|
||||
registerEnableCommand(smartcliInstance);
|
||||
|
@@ -95,6 +95,16 @@ export class ProcessManager extends EventEmitter {
|
||||
|
||||
// Check if process with this id already exists
|
||||
if (this.processes.has(config.id)) {
|
||||
const existing = this.processes.get(config.id)!;
|
||||
// If an existing monitor is present but not running, treat this as a fresh start via restart logic
|
||||
if (!existing.isRunning()) {
|
||||
this.logger.info(
|
||||
`Existing monitor found for id '${config.id}' but not running. Restarting it...`,
|
||||
);
|
||||
await this.restart(config.id);
|
||||
return;
|
||||
}
|
||||
// Already running – surface a meaningful error
|
||||
throw new ValidationError(
|
||||
`Process with id '${config.id}' already exists`,
|
||||
'ERR_DUPLICATE_PROCESS',
|
||||
@@ -246,7 +256,8 @@ export class ProcessManager extends EventEmitter {
|
||||
|
||||
try {
|
||||
await monitor.stop();
|
||||
this.updateProcessInfo(id, { status: 'stopped' });
|
||||
// Ensure status and PID are reflected immediately
|
||||
this.updateProcessInfo(id, { status: 'stopped', pid: undefined });
|
||||
this.logger.info(`Successfully stopped process with id '${id}'`);
|
||||
} catch (error: Error | unknown) {
|
||||
const processError = new ProcessError(
|
||||
@@ -430,6 +441,8 @@ export class ProcessManager extends EventEmitter {
|
||||
const pid = monitor.getPid();
|
||||
if (pid) {
|
||||
info.pid = pid;
|
||||
} else {
|
||||
info.pid = undefined;
|
||||
}
|
||||
|
||||
// Update uptime if available
|
||||
@@ -449,9 +462,7 @@ export class ProcessManager extends EventEmitter {
|
||||
info.restarts = monitor.getRestartCount();
|
||||
|
||||
// Update status based on actual running state
|
||||
if (monitor.isRunning()) {
|
||||
info.status = 'online';
|
||||
}
|
||||
info.status = monitor.isRunning() ? 'online' : 'stopped';
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -93,6 +93,9 @@ export class ProcessWrapper extends EventEmitter {
|
||||
this.stdoutRemainder = '';
|
||||
this.stderrRemainder = '';
|
||||
|
||||
// Mark process reference as gone so isRunning() reflects reality
|
||||
this.process = null;
|
||||
|
||||
this.emit('exit', code, signal);
|
||||
});
|
||||
|
||||
@@ -269,6 +272,7 @@ export class ProcessWrapper extends EventEmitter {
|
||||
* Get the process ID if running
|
||||
*/
|
||||
public getPid(): number | null {
|
||||
if (!this.isRunning()) return null;
|
||||
return this.process?.pid || null;
|
||||
}
|
||||
|
||||
@@ -292,7 +296,13 @@ export class ProcessWrapper extends EventEmitter {
|
||||
* Check if the process is currently running
|
||||
*/
|
||||
public isRunning(): boolean {
|
||||
return this.process !== null && typeof this.process.exitCode !== 'number';
|
||||
if (!this.process) return false;
|
||||
// In Node, while the child is running: exitCode === null and signalCode === null/undefined
|
||||
// After it exits: exitCode is a number OR signalCode is a string
|
||||
const anyProc: any = this.process as any;
|
||||
const exitCode = anyProc.exitCode;
|
||||
const signalCode = anyProc.signalCode;
|
||||
return exitCode === null && (signalCode === null || typeof signalCode === 'undefined');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -10,6 +10,7 @@ import type {
|
||||
DaemonStatusResponse,
|
||||
HeartbeatResponse,
|
||||
} from '../shared/protocol/ipc.types.js';
|
||||
import { LogPersistence } from './logpersistence.js';
|
||||
|
||||
/**
|
||||
* Central daemon server that manages all TSPM processes
|
||||
@@ -170,7 +171,22 @@ export class TspmDaemon {
|
||||
throw new Error(`Process ${id} not found`);
|
||||
}
|
||||
await this.tspmInstance.setDesiredState(id, 'online');
|
||||
await this.tspmInstance.start(config);
|
||||
const existing = this.tspmInstance.processes.get(id);
|
||||
if (existing) {
|
||||
if (existing.isRunning()) {
|
||||
// Already running; return current status/pid
|
||||
const runningInfo = this.tspmInstance.processInfo.get(id);
|
||||
return {
|
||||
processId: id,
|
||||
pid: runningInfo?.pid,
|
||||
status: runningInfo?.status || 'online',
|
||||
};
|
||||
} else {
|
||||
await this.tspmInstance.restart(id);
|
||||
}
|
||||
} else {
|
||||
await this.tspmInstance.start(config);
|
||||
}
|
||||
const processInfo = this.tspmInstance.processInfo.get(id);
|
||||
return {
|
||||
processId: id,
|
||||
@@ -501,6 +517,28 @@ export class TspmDaemon {
|
||||
'daemon:status',
|
||||
async (request: RequestForMethod<'daemon:status'>) => {
|
||||
const memUsage = process.memoryUsage();
|
||||
// Aggregate log stats from monitors
|
||||
let totalLogCount = 0;
|
||||
let totalLogBytes = 0;
|
||||
const perProcess: Array<{ id: ProcessId; count: number; bytes: number }> = [];
|
||||
for (const [id, monitor] of this.tspmInstance.processes.entries()) {
|
||||
try {
|
||||
const logs = monitor.getLogs();
|
||||
const count = logs.length;
|
||||
const bytes = LogPersistence.calculateLogMemorySize(logs);
|
||||
totalLogCount += count;
|
||||
totalLogBytes += bytes;
|
||||
perProcess.push({ id, count, bytes });
|
||||
} catch {}
|
||||
}
|
||||
const pathsInfo = {
|
||||
tspmDir: paths.tspmDir,
|
||||
socketPath: this.socketPath,
|
||||
pidFile: this.daemonPidFile,
|
||||
};
|
||||
const configsInfo = {
|
||||
processConfigs: this.tspmInstance.processConfigs.size,
|
||||
};
|
||||
return {
|
||||
status: 'running',
|
||||
pid: process.pid,
|
||||
@@ -509,6 +547,13 @@ export class TspmDaemon {
|
||||
memoryUsage: memUsage.heapUsed,
|
||||
cpuUsage: process.cpuUsage().user / 1000000, // Convert to seconds
|
||||
version: this.version,
|
||||
logsInMemory: {
|
||||
totalCount: totalLogCount,
|
||||
totalBytes: totalLogBytes,
|
||||
perProcess,
|
||||
},
|
||||
paths: pathsInfo,
|
||||
configs: configsInfo,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
@@ -228,6 +228,20 @@ export interface DaemonStatusResponse {
|
||||
memoryUsage?: number;
|
||||
cpuUsage?: number;
|
||||
version?: string;
|
||||
// Additional metadata (optional)
|
||||
paths?: {
|
||||
tspmDir?: string;
|
||||
socketPath?: string;
|
||||
pidFile?: string;
|
||||
};
|
||||
configs?: {
|
||||
processConfigs?: number;
|
||||
};
|
||||
logsInMemory?: {
|
||||
totalCount: number;
|
||||
totalBytes: number;
|
||||
perProcess: Array<{ id: ProcessId; count: number; bytes: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
// Daemon shutdown command
|
||||
|
Reference in New Issue
Block a user