cron-manager.js
1 #!/usr/bin/env node 2 3 /** 4 * Cron Job Manager CLI 5 * 6 * Easy management of scheduled tasks via database. 7 * 8 * Commands: 9 * list - List all cron jobs with status 10 * enable <task_key> - Enable a cron job 11 * disable <task_key> - Disable a cron job 12 * add - Add a new cron job (interactive) 13 * edit <task_key> - Edit an existing job (interactive) 14 * remove <task_key> - Remove a cron job 15 * run <task_key> - Manually trigger a job 16 * logs <task_key> - View execution logs for a job 17 * stats - Show cron job statistics 18 */ 19 20 import { run, getOne, getAll } from '../utils/db.js'; 21 import Logger from '../utils/logger.js'; 22 import '../utils/load-env.js'; 23 24 const logger = new Logger('CronManager'); 25 26 /** 27 * List all cron jobs 28 */ 29 async function listJobs(filter = null) { 30 const jobs = 31 filter !== null 32 ? await getAll( 33 'SELECT * FROM ops.cron_jobs WHERE enabled = $1 ORDER BY interval_value, interval_unit', 34 [filter] 35 ) 36 : await getAll('SELECT * FROM ops.cron_jobs ORDER BY interval_value, interval_unit'); 37 38 if (jobs.length === 0) { 39 logger.warn('No cron jobs found. Run migration and seed script first.'); 40 return; 41 } 42 43 console.log('\n╔════════════════════════════════════════════════════════════════════════════╗'); 44 console.log('║ CRON JOBS MANAGER ║'); 45 console.log('╚════════════════════════════════════════════════════════════════════════════╝\n'); 46 47 // Group by interval 48 const grouped = {}; 49 for (const job of jobs) { 50 const interval = `${job.interval_value} ${job.interval_unit}`; 51 if (!grouped[interval]) grouped[interval] = []; 52 grouped[interval].push(job); 53 } 54 55 for (const [interval, intervalJobs] of Object.entries(grouped)) { 56 console.log(`\n📅 Every ${interval}:`); 57 console.log('─'.repeat(80)); 58 59 for (const job of intervalJobs) { 60 const status = job.enabled ? '✅ Enabled' : '⏸️ Disabled'; 61 const lastRun = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : 'Never run'; 62 63 console.log(`\n ${status} ${job.name}`); 64 console.log(` Key: ${job.task_key}`); 65 console.log(` Type: ${job.handler_type} | Handler: ${job.handler_value}`); 66 console.log(` Last run: ${lastRun}`); 67 if (job.description) { 68 console.log(` Description: ${job.description}`); 69 } 70 } 71 } 72 73 console.log(`\n${'─'.repeat(80)}`); 74 console.log(`Total: ${jobs.length} jobs`); 75 const enabledCount = jobs.filter(j => j.enabled).length; 76 console.log(`Status: ${enabledCount} enabled, ${jobs.length - enabledCount} disabled\n`); 77 } 78 79 /** 80 * Enable a cron job 81 */ 82 async function enableJob(taskKey) { 83 const job = await getOne('SELECT * FROM ops.cron_jobs WHERE task_key = $1', [taskKey]); 84 85 if (!job) { 86 logger.error(`Job not found: ${taskKey}`); 87 return 1; 88 } 89 90 if (job.enabled) { 91 logger.warn(`Job already enabled: ${job.name}`); 92 return 0; 93 } 94 95 await run('UPDATE ops.cron_jobs SET enabled = true WHERE task_key = $1', [taskKey]); 96 logger.success(`✅ Enabled: ${job.name}`); 97 return 0; 98 } 99 100 /** 101 * Disable a cron job 102 */ 103 async function disableJob(taskKey) { 104 const job = await getOne('SELECT * FROM ops.cron_jobs WHERE task_key = $1', [taskKey]); 105 106 if (!job) { 107 logger.error(`Job not found: ${taskKey}`); 108 return 1; 109 } 110 111 if (!job.enabled) { 112 logger.warn(`Job already disabled: ${job.name}`); 113 return 0; 114 } 115 116 await run('UPDATE ops.cron_jobs SET enabled = false WHERE task_key = $1', [taskKey]); 117 logger.success(`⏸️ Disabled: ${job.name}`); 118 return 0; 119 } 120 121 /** 122 * Remove a cron job 123 */ 124 async function removeJob(taskKey) { 125 const job = await getOne('SELECT * FROM ops.cron_jobs WHERE task_key = $1', [taskKey]); 126 127 if (!job) { 128 logger.error(`Job not found: ${taskKey}`); 129 return 1; 130 } 131 132 await run('DELETE FROM ops.cron_jobs WHERE task_key = $1', [taskKey]); 133 logger.success(`🗑️ Removed: ${job.name}`); 134 return 0; 135 } 136 137 /** 138 * View execution logs for a job 139 */ 140 async function viewLogs(taskKey, limit = 10) { 141 const job = await getOne('SELECT * FROM ops.cron_jobs WHERE task_key = $1', [taskKey]); 142 143 if (!job) { 144 logger.error(`Job not found: ${taskKey}`); 145 return 1; 146 } 147 148 const logs = await getAll( 149 `SELECT * FROM ops.cron_job_logs 150 WHERE job_name = $1 151 ORDER BY started_at DESC 152 LIMIT $2`, 153 [job.name, limit] 154 ); 155 156 if (logs.length === 0) { 157 logger.warn(`No execution logs found for: ${job.name}`); 158 return 0; 159 } 160 161 console.log(`\n📋 Execution Logs for: ${job.name}`); 162 console.log('─'.repeat(80)); 163 164 for (const log of logs) { 165 const duration = log.finished_at 166 ? ((new Date(log.finished_at) - new Date(log.started_at)) / 1000).toFixed(2) 167 : 'N/A'; 168 const statusIcon = log.status === 'success' ? '✅' : log.status === 'failed' ? '❌' : '🔄'; 169 170 console.log(`\n ${statusIcon} ${log.status.toUpperCase()}`); 171 console.log(` Started: ${new Date(log.started_at).toLocaleString()}`); 172 console.log(` Duration: ${duration}s`); 173 console.log(` Items: ${log.items_processed || 0} processed, ${log.items_failed || 0} failed`); 174 175 if (log.summary) { 176 console.log(` Summary: ${log.summary}`); 177 } 178 179 if (log.error_message) { 180 console.log(` Error: ${log.error_message}`); 181 } 182 } 183 184 console.log(`\n${'─'.repeat(80)}\n`); 185 return 0; 186 } 187 188 /** 189 * Show statistics 190 */ 191 async function showStats() { 192 const [totalRow, enabledRow, disabledRow, totalExecRow, successExecRow, failedExecRow] = 193 await Promise.all([ 194 getOne('SELECT COUNT(*) as count FROM ops.cron_jobs'), 195 getOne('SELECT COUNT(*) as count FROM ops.cron_jobs WHERE enabled = true'), 196 getOne('SELECT COUNT(*) as count FROM ops.cron_jobs WHERE enabled = false'), 197 getOne('SELECT COUNT(*) as count FROM ops.cron_job_logs'), 198 getOne("SELECT COUNT(*) as count FROM ops.cron_job_logs WHERE status = 'success'"), 199 getOne("SELECT COUNT(*) as count FROM ops.cron_job_logs WHERE status = 'failed'"), 200 ]); 201 202 const stats = { 203 total: Number(totalRow.count), 204 enabled: Number(enabledRow.count), 205 disabled: Number(disabledRow.count), 206 totalExecutions: Number(totalExecRow.count), 207 successfulExecutions: Number(successExecRow.count), 208 failedExecutions: Number(failedExecRow.count), 209 }; 210 211 const recentFailures = await getAll( 212 `SELECT job_name, COUNT(*) as count 213 FROM ops.cron_job_logs 214 WHERE status = 'failed' AND started_at >= NOW() - INTERVAL '7 days' 215 GROUP BY job_name 216 ORDER BY count DESC 217 LIMIT 5` 218 ); 219 220 console.log('\n╔════════════════════════════════════════════════════════════════════════════╗'); 221 console.log('║ CRON JOBS STATISTICS ║'); 222 console.log('╚════════════════════════════════════════════════════════════════════════════╝\n'); 223 224 console.log('📊 Job Counts:'); 225 console.log(` Total Jobs: ${stats.total}`); 226 console.log(` Enabled: ${stats.enabled} (${((stats.enabled / stats.total) * 100).toFixed(1)}%)`); 227 console.log( 228 ` Disabled: ${stats.disabled} (${((stats.disabled / stats.total) * 100).toFixed(1)}%)` 229 ); 230 231 console.log('\n📈 Execution History:'); 232 console.log(` Total Executions: ${stats.totalExecutions}`); 233 console.log( 234 ` Successful: ${stats.successfulExecutions} (${((stats.successfulExecutions / stats.totalExecutions) * 100).toFixed(1)}%)` 235 ); 236 console.log( 237 ` Failed: ${stats.failedExecutions} (${((stats.failedExecutions / stats.totalExecutions) * 100).toFixed(1)}%)` 238 ); 239 240 if (recentFailures.length > 0) { 241 console.log('\n⚠️ Recent Failures (Last 7 Days):'); 242 for (const failure of recentFailures) { 243 console.log(` ${failure.job_name}: ${failure.count} failures`); 244 } 245 } 246 247 console.log(`\n${'─'.repeat(80)}\n`); 248 } 249 250 /** 251 * Add a new cron job (interactive) 252 */ 253 async function addJob(options = {}) { 254 if (!options.name || !options.taskKey || !options.handlerValue) { 255 console.log('\n❌ Missing required options. Usage:'); 256 console.log( 257 ' npm run cron:add -- --name "Job Name" --key jobKey --handler "npm run cmd" --interval 5 --unit minutes --type command' 258 ); 259 console.log('\nRequired:'); 260 console.log(' --name Human-readable job name'); 261 console.log(' --key Unique programmatic identifier (camelCase)'); 262 console.log(' --handler Command to run or function name'); 263 console.log(' --type Handler type: "command" or "function"'); 264 console.log(' --interval Interval value (number)'); 265 console.log(' --unit Interval unit: minutes, hours, days, weeks'); 266 console.log('\nOptional:'); 267 console.log(' --description Job description'); 268 console.log(' --enabled Enable job (true/false, default: true)'); 269 return 1; 270 } 271 272 // Check if task_key already exists 273 const existing = await getOne( 274 'SELECT task_key FROM ops.cron_jobs WHERE task_key = $1', 275 [options.taskKey] 276 ); 277 if (existing) { 278 logger.error(`Job already exists with task_key: ${options.taskKey}`); 279 return 1; 280 } 281 282 // Insert new job 283 await run( 284 `INSERT INTO ops.cron_jobs ( 285 name, task_key, description, handler_type, handler_value, 286 interval_value, interval_unit, enabled 287 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, 288 [ 289 options.name, 290 options.taskKey, 291 options.description || null, 292 options.type || 'command', 293 options.handlerValue, 294 options.interval || 60, 295 options.unit || 'minutes', 296 options.enabled !== false ? true : false, 297 ] 298 ); 299 300 logger.success(`✅ Added new job: ${options.name}`); 301 return 0; 302 } 303 304 /** 305 * Parse CLI arguments 306 */ 307 function parseArgs(args) { 308 const parsed = {}; 309 for (let i = 0; i < args.length; i++) { 310 const arg = args[i]; 311 if (arg.startsWith('--')) { 312 const key = arg.slice(2); 313 const value = args[i + 1]; 314 parsed[key] = value; 315 i++; // Skip next arg 316 } 317 } 318 return parsed; 319 } 320 321 /** 322 * Main CLI handler 323 */ 324 async function main() { 325 const [command, ...args] = process.argv.slice(2); 326 327 if (!command) { 328 console.log('\n🔧 Cron Job Manager\n'); 329 console.log('Usage: npm run cron:manage <command> [args]\n'); 330 console.log('Commands:'); 331 console.log(' list - List all cron jobs'); 332 console.log(' list --enabled - List enabled jobs only'); 333 console.log(' list --disabled - List disabled jobs only'); 334 console.log(' enable <task_key> - Enable a job'); 335 console.log(' disable <task_key> - Disable a job'); 336 console.log(' remove <task_key> - Remove a job'); 337 console.log(' logs <task_key> - View execution logs'); 338 console.log(' stats - Show statistics'); 339 console.log(' add [options] - Add a new job\n'); 340 return 1; 341 } 342 343 switch (command) { 344 case 'list': { 345 const filter = args.includes('--enabled') ? true : args.includes('--disabled') ? false : null; 346 await listJobs(filter); 347 return 0; 348 } 349 350 case 'enable': { 351 const taskKey = args[0]; 352 if (!taskKey) { 353 logger.error('Missing task_key. Usage: npm run cron:enable <task_key>'); 354 return 1; 355 } 356 return await enableJob(taskKey); 357 } 358 359 case 'disable': { 360 const taskKey = args[0]; 361 if (!taskKey) { 362 logger.error('Missing task_key. Usage: npm run cron:disable <task_key>'); 363 return 1; 364 } 365 return await disableJob(taskKey); 366 } 367 368 case 'remove': { 369 const taskKey = args[0]; 370 if (!taskKey) { 371 logger.error('Missing task_key. Usage: npm run cron:remove <task_key>'); 372 return 1; 373 } 374 return await removeJob(taskKey); 375 } 376 377 case 'logs': { 378 const taskKey = args[0]; 379 if (!taskKey) { 380 logger.error('Missing task_key. Usage: npm run cron:logs <task_key>'); 381 return 1; 382 } 383 return await viewLogs(taskKey); 384 } 385 386 case 'stats': 387 await showStats(); 388 return 0; 389 390 case 'add': { 391 const options = parseArgs(args); 392 return await addJob(options); 393 } 394 395 default: 396 logger.error(`Unknown command: ${command}`); 397 return 1; 398 } 399 } 400 401 // Run if called directly 402 if (import.meta.url === `file://${process.argv[1]}`) { 403 main() 404 .then(code => process.exit(code)) 405 .catch(error => { 406 console.error('Fatal error:', error); 407 process.exit(1); 408 }); 409 } 410 411 export { listJobs, enableJob, disableJob, removeJob, viewLogs, showStats, addJob, parseArgs };