From 0ad1a96871935e6da41a0c1542638bc9242a9562 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Fri, 10 Oct 2025 12:24:23 +0100 Subject: [PATCH] Building the start of Automation page and implemented BullMQ module --- backend/package.json | 4 + backend/src/routes/automationRoutes.js | 362 +++++++++ backend/src/server.js | 46 +- backend/src/services/automation/echoHello.js | 67 ++ .../services/automation/githubUpdateCheck.js | 153 ++++ backend/src/services/automation/index.js | 283 +++++++ .../automation/orphanedRepoCleanup.js | 114 +++ .../src/services/automation/sessionCleanup.js | 78 ++ .../src/services/automation/shared/prisma.js | 5 + .../src/services/automation/shared/redis.js | 16 + .../src/services/automation/shared/utils.js | 82 ++ frontend/src/App.jsx | 6 +- frontend/src/components/Layout.jsx | 22 +- frontend/src/pages/Automation.jsx | 581 +++++++++++++++ frontend/src/pages/Queue.jsx | 699 ------------------ package-lock.json | 385 +++++++++- 16 files changed, 2154 insertions(+), 749 deletions(-) create mode 100644 backend/src/routes/automationRoutes.js create mode 100644 backend/src/services/automation/echoHello.js create mode 100644 backend/src/services/automation/githubUpdateCheck.js create mode 100644 backend/src/services/automation/index.js create mode 100644 backend/src/services/automation/orphanedRepoCleanup.js create mode 100644 backend/src/services/automation/sessionCleanup.js create mode 100644 backend/src/services/automation/shared/prisma.js create mode 100644 backend/src/services/automation/shared/redis.js create mode 100644 backend/src/services/automation/shared/utils.js create mode 100644 frontend/src/pages/Automation.jsx delete mode 100644 frontend/src/pages/Queue.jsx diff --git a/backend/package.json b/backend/package.json index a12467e..87de98b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,14 +14,18 @@ "db:studio": "prisma studio" }, "dependencies": { + "@bull-board/api": "^6.13.0", + "@bull-board/express": "^6.13.0", "@prisma/client": "^6.1.0", "bcryptjs": "^2.4.3", + "bullmq": "^5.61.0", "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", "express-rate-limit": "^7.5.0", "express-validator": "^7.2.0", "helmet": "^8.0.0", + "ioredis": "^5.8.1", "jsonwebtoken": "^9.0.2", "moment": "^2.30.1", "qrcode": "^1.5.4", diff --git a/backend/src/routes/automationRoutes.js b/backend/src/routes/automationRoutes.js new file mode 100644 index 0000000..6498696 --- /dev/null +++ b/backend/src/routes/automationRoutes.js @@ -0,0 +1,362 @@ +const express = require("express"); +const { queueManager, QUEUE_NAMES } = require("../services/automation"); +const { authenticateToken } = require("../middleware/auth"); + +const router = express.Router(); + +// Get all queue statistics +router.get("/stats", authenticateToken, async (req, res) => { + try { + const stats = await queueManager.getAllQueueStats(); + res.json({ + success: true, + data: stats, + }); + } catch (error) { + console.error("Error fetching queue stats:", error); + res.status(500).json({ + success: false, + error: "Failed to fetch queue statistics", + }); + } +}); + +// Get specific queue statistics +router.get("/stats/:queueName", authenticateToken, async (req, res) => { + try { + const { queueName } = req.params; + + if (!Object.values(QUEUE_NAMES).includes(queueName)) { + return res.status(400).json({ + success: false, + error: "Invalid queue name", + }); + } + + const stats = await queueManager.getQueueStats(queueName); + res.json({ + success: true, + data: stats, + }); + } catch (error) { + console.error("Error fetching queue stats:", error); + res.status(500).json({ + success: false, + error: "Failed to fetch queue statistics", + }); + } +}); + +// Get recent jobs for a queue +router.get("/jobs/:queueName", authenticateToken, async (req, res) => { + try { + const { queueName } = req.params; + const { limit = 10 } = req.query; + + if (!Object.values(QUEUE_NAMES).includes(queueName)) { + return res.status(400).json({ + success: false, + error: "Invalid queue name", + }); + } + + const jobs = await queueManager.getRecentJobs(queueName, parseInt(limit)); + + // Format jobs for frontend + const formattedJobs = jobs.map((job) => ({ + id: job.id, + name: job.name, + status: job.finishedOn + ? job.failedReason + ? "failed" + : "completed" + : "active", + progress: job.progress, + data: job.data, + returnvalue: job.returnvalue, + failedReason: job.failedReason, + processedOn: job.processedOn, + finishedOn: job.finishedOn, + createdAt: new Date(job.timestamp), + attemptsMade: job.attemptsMade, + delay: job.delay, + })); + + res.json({ + success: true, + data: formattedJobs, + }); + } catch (error) { + console.error("Error fetching recent jobs:", error); + res.status(500).json({ + success: false, + error: "Failed to fetch recent jobs", + }); + } +}); + +// Trigger manual GitHub update check +router.post("/trigger/github-update", authenticateToken, async (req, res) => { + try { + const job = await queueManager.triggerGitHubUpdateCheck(); + res.json({ + success: true, + data: { + jobId: job.id, + message: "GitHub update check triggered successfully", + }, + }); + } catch (error) { + console.error("Error triggering GitHub update check:", error); + res.status(500).json({ + success: false, + error: "Failed to trigger GitHub update check", + }); + } +}); + +// Trigger manual session cleanup +router.post("/trigger/session-cleanup", authenticateToken, async (req, res) => { + try { + const job = await queueManager.triggerSessionCleanup(); + res.json({ + success: true, + data: { + jobId: job.id, + message: "Session cleanup triggered successfully", + }, + }); + } catch (error) { + console.error("Error triggering session cleanup:", error); + res.status(500).json({ + success: false, + error: "Failed to trigger session cleanup", + }); + } +}); + +// Trigger manual echo hello +router.post("/trigger/echo-hello", authenticateToken, async (req, res) => { + try { + const { message } = req.body; + const job = await queueManager.triggerEchoHello(message); + res.json({ + success: true, + data: { + jobId: job.id, + message: "Echo hello triggered successfully", + }, + }); + } catch (error) { + console.error("Error triggering echo hello:", error); + res.status(500).json({ + success: false, + error: "Failed to trigger echo hello", + }); + } +}); + +// Trigger manual orphaned repo cleanup +router.post( + "/trigger/orphaned-repo-cleanup", + authenticateToken, + async (req, res) => { + try { + const job = await queueManager.triggerOrphanedRepoCleanup(); + res.json({ + success: true, + data: { + jobId: job.id, + message: "Orphaned repository cleanup triggered successfully", + }, + }); + } catch (error) { + console.error("Error triggering orphaned repository cleanup:", error); + res.status(500).json({ + success: false, + error: "Failed to trigger orphaned repository cleanup", + }); + } + }, +); + +// Get queue health status +router.get("/health", authenticateToken, async (req, res) => { + try { + const stats = await queueManager.getAllQueueStats(); + const totalJobs = Object.values(stats).reduce((sum, queueStats) => { + return sum + queueStats.waiting + queueStats.active + queueStats.failed; + }, 0); + + const health = { + status: "healthy", + totalJobs, + queues: Object.keys(stats).length, + timestamp: new Date().toISOString(), + }; + + // Check for unhealthy conditions + if (totalJobs > 1000) { + health.status = "warning"; + health.message = "High number of queued jobs"; + } + + const failedJobs = Object.values(stats).reduce((sum, queueStats) => { + return sum + queueStats.failed; + }, 0); + + if (failedJobs > 10) { + health.status = "error"; + health.message = "High number of failed jobs"; + } + + res.json({ + success: true, + data: health, + }); + } catch (error) { + console.error("Error checking queue health:", error); + res.status(500).json({ + success: false, + error: "Failed to check queue health", + }); + } +}); + +// Get automation overview (for dashboard cards) +router.get("/overview", authenticateToken, async (req, res) => { + try { + const stats = await queueManager.getAllQueueStats(); + + // Get recent jobs for each queue to show last run times + const recentJobs = await Promise.all([ + queueManager.getRecentJobs(QUEUE_NAMES.GITHUB_UPDATE_CHECK, 1), + queueManager.getRecentJobs(QUEUE_NAMES.SESSION_CLEANUP, 1), + queueManager.getRecentJobs(QUEUE_NAMES.ECHO_HELLO, 1), + queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_REPO_CLEANUP, 1), + ]); + + // Calculate overview metrics + const overview = { + scheduledTasks: + stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].delayed + + stats[QUEUE_NAMES.SESSION_CLEANUP].delayed + + stats[QUEUE_NAMES.SYSTEM_MAINTENANCE].delayed + + stats[QUEUE_NAMES.ECHO_HELLO].delayed + + stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].delayed, + + runningTasks: + stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].active + + stats[QUEUE_NAMES.SESSION_CLEANUP].active + + stats[QUEUE_NAMES.SYSTEM_MAINTENANCE].active + + stats[QUEUE_NAMES.ECHO_HELLO].active + + stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].active, + + failedTasks: + stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].failed + + stats[QUEUE_NAMES.SESSION_CLEANUP].failed + + stats[QUEUE_NAMES.SYSTEM_MAINTENANCE].failed + + stats[QUEUE_NAMES.ECHO_HELLO].failed + + stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].failed, + + totalAutomations: Object.values(stats).reduce((sum, queueStats) => { + return ( + sum + + queueStats.completed + + queueStats.failed + + queueStats.active + + queueStats.waiting + + queueStats.delayed + ); + }, 0), + + // Automation details with last run times + automations: [ + { + name: "GitHub Update Check", + queue: QUEUE_NAMES.GITHUB_UPDATE_CHECK, + description: "Checks for new PatchMon releases", + schedule: "Daily at midnight", + lastRun: recentJobs[0][0]?.finishedOn + ? new Date(recentJobs[0][0].finishedOn).toLocaleString() + : "Never", + lastRunTimestamp: recentJobs[0][0]?.finishedOn || 0, + status: recentJobs[0][0]?.failedReason + ? "Failed" + : recentJobs[0][0] + ? "Success" + : "Never run", + stats: stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK], + }, + { + name: "Session Cleanup", + queue: QUEUE_NAMES.SESSION_CLEANUP, + description: "Cleans up expired user sessions", + schedule: "Every hour", + lastRun: recentJobs[1][0]?.finishedOn + ? new Date(recentJobs[1][0].finishedOn).toLocaleString() + : "Never", + lastRunTimestamp: recentJobs[1][0]?.finishedOn || 0, + status: recentJobs[1][0]?.failedReason + ? "Failed" + : recentJobs[1][0] + ? "Success" + : "Never run", + stats: stats[QUEUE_NAMES.SESSION_CLEANUP], + }, + { + name: "Echo Hello", + queue: QUEUE_NAMES.ECHO_HELLO, + description: "Simple test automation task", + schedule: "Manual only", + lastRun: recentJobs[2][0]?.finishedOn + ? new Date(recentJobs[2][0].finishedOn).toLocaleString() + : "Never", + lastRunTimestamp: recentJobs[2][0]?.finishedOn || 0, + status: recentJobs[2][0]?.failedReason + ? "Failed" + : recentJobs[2][0] + ? "Success" + : "Never run", + stats: stats[QUEUE_NAMES.ECHO_HELLO], + }, + { + name: "Orphaned Repo Cleanup", + queue: QUEUE_NAMES.ORPHANED_REPO_CLEANUP, + description: "Removes repositories with no associated hosts", + schedule: "Daily at 2 AM", + lastRun: recentJobs[3][0]?.finishedOn + ? new Date(recentJobs[3][0].finishedOn).toLocaleString() + : "Never", + lastRunTimestamp: recentJobs[3][0]?.finishedOn || 0, + status: recentJobs[3][0]?.failedReason + ? "Failed" + : recentJobs[3][0] + ? "Success" + : "Never run", + stats: stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP], + }, + ].sort((a, b) => { + // Sort by last run timestamp (most recent first) + // If both have never run (timestamp 0), maintain original order + if (a.lastRunTimestamp === 0 && b.lastRunTimestamp === 0) return 0; + if (a.lastRunTimestamp === 0) return 1; // Never run goes to bottom + if (b.lastRunTimestamp === 0) return -1; // Never run goes to bottom + return b.lastRunTimestamp - a.lastRunTimestamp; // Most recent first + }), + }; + + res.json({ + success: true, + data: overview, + }); + } catch (error) { + console.error("Error fetching automation overview:", error); + res.status(500).json({ + success: false, + error: "Failed to fetch automation overview", + }); + } +}); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index 58bcbbb..c290da2 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -61,10 +61,9 @@ const repositoryRoutes = require("./routes/repositoryRoutes"); const versionRoutes = require("./routes/versionRoutes"); const tfaRoutes = require("./routes/tfaRoutes"); const searchRoutes = require("./routes/searchRoutes"); -const autoEnrollmentRoutes = require("./routes/autoEnrollmentRoutes"); -const updateScheduler = require("./services/updateScheduler"); +const automationRoutes = require("./routes/automationRoutes"); +const { queueManager } = require("./services/automation"); const { initSettings } = require("./services/settingsService"); -const { cleanup_expired_sessions } = require("./utils/session_manager"); // Initialize Prisma client with optimized connection pooling for multiple instances const prisma = createPrismaClient(); @@ -417,11 +416,7 @@ app.use(`/api/${apiVersion}/repositories`, repositoryRoutes); app.use(`/api/${apiVersion}/version`, versionRoutes); app.use(`/api/${apiVersion}/tfa`, tfaRoutes); app.use(`/api/${apiVersion}/search`, searchRoutes); -app.use( - `/api/${apiVersion}/auto-enrollment`, - authLimiter, - autoEnrollmentRoutes, -); +app.use(`/api/${apiVersion}/automation`, automationRoutes); // Error handling middleware app.use((err, _req, res, _next) => { @@ -444,10 +439,7 @@ process.on("SIGINT", async () => { if (process.env.ENABLE_LOGGING === "true") { logger.info("SIGINT received, shutting down gracefully"); } - if (app.locals.session_cleanup_interval) { - clearInterval(app.locals.session_cleanup_interval); - } - updateScheduler.stop(); + await queueManager.shutdown(); await disconnectPrisma(prisma); process.exit(0); }); @@ -456,10 +448,7 @@ process.on("SIGTERM", async () => { if (process.env.ENABLE_LOGGING === "true") { logger.info("SIGTERM received, shutting down gracefully"); } - if (app.locals.session_cleanup_interval) { - clearInterval(app.locals.session_cleanup_interval); - } - updateScheduler.stop(); + await queueManager.shutdown(); await disconnectPrisma(prisma); process.exit(0); }); @@ -728,34 +717,19 @@ async function startServer() { // Initialize dashboard preferences for all users await initializeDashboardPreferences(); - // Initial session cleanup - await cleanup_expired_sessions(); + // Initialize BullMQ queue manager + await queueManager.initialize(); - // Schedule session cleanup every hour - const session_cleanup_interval = setInterval( - async () => { - try { - await cleanup_expired_sessions(); - } catch (error) { - console.error("Session cleanup error:", error); - } - }, - 60 * 60 * 1000, - ); // Every hour + // Schedule recurring jobs + await queueManager.scheduleAllJobs(); app.listen(PORT, () => { if (process.env.ENABLE_LOGGING === "true") { logger.info(`Server running on port ${PORT}`); logger.info(`Environment: ${process.env.NODE_ENV}`); - logger.info("โœ… Session cleanup scheduled (every hour)"); + logger.info("โœ… BullMQ queue manager started"); } - - // Start update scheduler - updateScheduler.start(); }); - - // Store interval for cleanup on shutdown - app.locals.session_cleanup_interval = session_cleanup_interval; } catch (error) { console.error("โŒ Failed to start server:", error.message); process.exit(1); diff --git a/backend/src/services/automation/echoHello.js b/backend/src/services/automation/echoHello.js new file mode 100644 index 0000000..18744a6 --- /dev/null +++ b/backend/src/services/automation/echoHello.js @@ -0,0 +1,67 @@ +/** + * Echo Hello Automation + * Simple test automation task + */ +class EchoHello { + constructor(queueManager) { + this.queueManager = queueManager; + this.queueName = "echo-hello"; + } + + /** + * Process echo hello job + */ + async process(job) { + const startTime = Date.now(); + console.log("๐Ÿ‘‹ Starting echo hello task..."); + + try { + // Simple echo task + const message = job.data.message || "Hello from BullMQ!"; + const timestamp = new Date().toISOString(); + + // Simulate some work + await new Promise((resolve) => setTimeout(resolve, 100)); + + const executionTime = Date.now() - startTime; + console.log(`โœ… Echo hello completed in ${executionTime}ms: ${message}`); + + return { + success: true, + message, + timestamp, + executionTime, + }; + } catch (error) { + const executionTime = Date.now() - startTime; + console.error( + `โŒ Echo hello failed after ${executionTime}ms:`, + error.message, + ); + throw error; + } + } + + /** + * Echo hello is manual only - no scheduling + */ + async schedule() { + console.log("โ„น๏ธ Echo hello is manual only - no scheduling needed"); + return null; + } + + /** + * Trigger manual echo hello + */ + async triggerManual(message = "Hello from BullMQ!") { + const job = await this.queueManager.queues[this.queueName].add( + "echo-hello-manual", + { message }, + { priority: 1 }, + ); + console.log("โœ… Manual echo hello triggered"); + return job; + } +} + +module.exports = EchoHello; diff --git a/backend/src/services/automation/githubUpdateCheck.js b/backend/src/services/automation/githubUpdateCheck.js new file mode 100644 index 0000000..0d561b7 --- /dev/null +++ b/backend/src/services/automation/githubUpdateCheck.js @@ -0,0 +1,153 @@ +const { prisma } = require("./shared/prisma"); +const { compareVersions, checkPublicRepo } = require("./shared/utils"); + +/** + * GitHub Update Check Automation + * Checks for new releases on GitHub using HTTPS API + */ +class GitHubUpdateCheck { + constructor(queueManager) { + this.queueManager = queueManager; + this.queueName = "github-update-check"; + } + + /** + * Process GitHub update check job + */ + async process(job) { + const startTime = Date.now(); + console.log("๐Ÿ” Starting GitHub update check..."); + + try { + // Get settings + const settings = await prisma.settings.findFirst(); + const DEFAULT_GITHUB_REPO = "https://github.com/patchMon/patchmon"; + const repoUrl = settings?.githubRepoUrl || DEFAULT_GITHUB_REPO; + let owner, repo; + + // Parse GitHub repository URL (supports both HTTPS and SSH formats) + if (repoUrl.includes("git@github.com:")) { + const match = repoUrl.match(/git@github\.com:([^/]+)\/([^/]+)\.git/); + if (match) { + [, owner, repo] = match; + } + } else if (repoUrl.includes("github.com/")) { + const match = repoUrl.match( + /github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/, + ); + if (match) { + [, owner, repo] = match; + } + } + + if (!owner || !repo) { + throw new Error("Could not parse GitHub repository URL"); + } + + // Always use HTTPS GitHub API (simpler and more reliable) + const latestVersion = await checkPublicRepo(owner, repo); + + if (!latestVersion) { + throw new Error("Could not determine latest version"); + } + + // Read version from package.json + let currentVersion = "1.2.7"; // fallback + try { + const packageJson = require("../../../package.json"); + if (packageJson?.version) { + currentVersion = packageJson.version; + } + } catch (packageError) { + console.warn( + "Could not read version from package.json:", + packageError.message, + ); + } + + const isUpdateAvailable = + compareVersions(latestVersion, currentVersion) > 0; + + // Update settings with check results + await prisma.settings.update({ + where: { id: settings.id }, + data: { + last_update_check: new Date(), + update_available: isUpdateAvailable, + latest_version: latestVersion, + }, + }); + + const executionTime = Date.now() - startTime; + console.log( + `โœ… GitHub update check completed in ${executionTime}ms - Current: ${currentVersion}, Latest: ${latestVersion}, Update Available: ${isUpdateAvailable}`, + ); + + return { + success: true, + currentVersion, + latestVersion, + isUpdateAvailable, + executionTime, + }; + } catch (error) { + const executionTime = Date.now() - startTime; + console.error( + `โŒ GitHub update check failed after ${executionTime}ms:`, + error.message, + ); + + // Update last check time even on error + try { + const settings = await prisma.settings.findFirst(); + if (settings) { + await prisma.settings.update({ + where: { id: settings.id }, + data: { + last_update_check: new Date(), + update_available: false, + }, + }); + } + } catch (updateError) { + console.error( + "โŒ Error updating last check time:", + updateError.message, + ); + } + + throw error; + } + } + + /** + * Schedule recurring GitHub update check (daily at midnight) + */ + async schedule() { + const job = await this.queueManager.queues[this.queueName].add( + "github-update-check", + {}, + { + repeat: { cron: "0 0 * * *" }, // Daily at midnight + jobId: "github-update-check-recurring", + }, + ); + console.log("โœ… GitHub update check scheduled"); + return job; + } + + /** + * Trigger manual GitHub update check + */ + async triggerManual() { + const job = await this.queueManager.queues[this.queueName].add( + "github-update-check-manual", + {}, + { priority: 1 }, + ); + console.log("โœ… Manual GitHub update check triggered"); + return job; + } +} + +module.exports = GitHubUpdateCheck; diff --git a/backend/src/services/automation/index.js b/backend/src/services/automation/index.js new file mode 100644 index 0000000..2d02aa2 --- /dev/null +++ b/backend/src/services/automation/index.js @@ -0,0 +1,283 @@ +const { Queue, Worker } = require("bullmq"); +const { redis, redisConnection } = require("./shared/redis"); +const { prisma } = require("./shared/prisma"); + +// Import automation classes +const GitHubUpdateCheck = require("./githubUpdateCheck"); +const SessionCleanup = require("./sessionCleanup"); +const OrphanedRepoCleanup = require("./orphanedRepoCleanup"); +const EchoHello = require("./echoHello"); + +// Queue names +const QUEUE_NAMES = { + GITHUB_UPDATE_CHECK: "github-update-check", + SESSION_CLEANUP: "session-cleanup", + SYSTEM_MAINTENANCE: "system-maintenance", + ECHO_HELLO: "echo-hello", + ORPHANED_REPO_CLEANUP: "orphaned-repo-cleanup", +}; + +/** + * Main Queue Manager + * Manages all BullMQ queues and workers + */ +class QueueManager { + constructor() { + this.queues = {}; + this.workers = {}; + this.automations = {}; + this.isInitialized = false; + } + + /** + * Initialize all queues, workers, and automations + */ + async initialize() { + try { + console.log("โœ… Redis connection successful"); + + // Initialize queues + await this.initializeQueues(); + + // Initialize automation classes + await this.initializeAutomations(); + + // Initialize workers + await this.initializeWorkers(); + + // Setup event listeners + this.setupEventListeners(); + + this.isInitialized = true; + console.log("โœ… Queue manager initialized successfully"); + } catch (error) { + console.error("โŒ Failed to initialize queue manager:", error.message); + throw error; + } + } + + /** + * Initialize all queues + */ + async initializeQueues() { + for (const [key, queueName] of Object.entries(QUEUE_NAMES)) { + this.queues[queueName] = new Queue(queueName, { + connection: redisConnection, + defaultJobOptions: { + removeOnComplete: 50, // Keep last 50 completed jobs + removeOnFail: 20, // Keep last 20 failed jobs + attempts: 3, // Retry failed jobs 3 times + backoff: { + type: "exponential", + delay: 2000, + }, + }, + }); + + console.log(`โœ… Queue '${queueName}' initialized`); + } + } + + /** + * Initialize automation classes + */ + async initializeAutomations() { + this.automations[QUEUE_NAMES.GITHUB_UPDATE_CHECK] = new GitHubUpdateCheck( + this, + ); + this.automations[QUEUE_NAMES.SESSION_CLEANUP] = new SessionCleanup(this); + this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP] = + new OrphanedRepoCleanup(this); + this.automations[QUEUE_NAMES.ECHO_HELLO] = new EchoHello(this); + + console.log("โœ… All automation classes initialized"); + } + + /** + * Initialize all workers + */ + async initializeWorkers() { + // GitHub Update Check Worker + this.workers[QUEUE_NAMES.GITHUB_UPDATE_CHECK] = new Worker( + QUEUE_NAMES.GITHUB_UPDATE_CHECK, + this.automations[QUEUE_NAMES.GITHUB_UPDATE_CHECK].process.bind( + this.automations[QUEUE_NAMES.GITHUB_UPDATE_CHECK], + ), + { + connection: redisConnection, + concurrency: 1, + }, + ); + + // Session Cleanup Worker + this.workers[QUEUE_NAMES.SESSION_CLEANUP] = new Worker( + QUEUE_NAMES.SESSION_CLEANUP, + this.automations[QUEUE_NAMES.SESSION_CLEANUP].process.bind( + this.automations[QUEUE_NAMES.SESSION_CLEANUP], + ), + { + connection: redisConnection, + concurrency: 1, + }, + ); + + // Orphaned Repo Cleanup Worker + this.workers[QUEUE_NAMES.ORPHANED_REPO_CLEANUP] = new Worker( + QUEUE_NAMES.ORPHANED_REPO_CLEANUP, + this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].process.bind( + this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP], + ), + { + connection: redisConnection, + concurrency: 1, + }, + ); + + // Echo Hello Worker + this.workers[QUEUE_NAMES.ECHO_HELLO] = new Worker( + QUEUE_NAMES.ECHO_HELLO, + this.automations[QUEUE_NAMES.ECHO_HELLO].process.bind( + this.automations[QUEUE_NAMES.ECHO_HELLO], + ), + { + connection: redisConnection, + concurrency: 1, + }, + ); + + // Add error handling for all workers + Object.values(this.workers).forEach((worker) => { + worker.on("error", (error) => { + console.error("Worker error:", error); + }); + }); + + console.log("โœ… All workers initialized"); + } + + /** + * Setup event listeners for all queues + */ + setupEventListeners() { + for (const queueName of Object.values(QUEUE_NAMES)) { + const queue = this.queues[queueName]; + queue.on("error", (error) => { + console.error(`โŒ Queue '${queueName}' experienced an error:`, error); + }); + queue.on("failed", (job, err) => { + console.error( + `โŒ Job '${job.id}' in queue '${queueName}' failed:`, + err, + ); + }); + queue.on("completed", (job) => { + console.log(`โœ… Job '${job.id}' in queue '${queueName}' completed.`); + }); + } + console.log("โœ… Queue events initialized"); + } + + /** + * Schedule all recurring jobs + */ + async scheduleAllJobs() { + await this.automations[QUEUE_NAMES.GITHUB_UPDATE_CHECK].schedule(); + await this.automations[QUEUE_NAMES.SESSION_CLEANUP].schedule(); + await this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].schedule(); + await this.automations[QUEUE_NAMES.ECHO_HELLO].schedule(); + } + + /** + * Manual job triggers + */ + async triggerGitHubUpdateCheck() { + return this.automations[QUEUE_NAMES.GITHUB_UPDATE_CHECK].triggerManual(); + } + + async triggerSessionCleanup() { + return this.automations[QUEUE_NAMES.SESSION_CLEANUP].triggerManual(); + } + + async triggerOrphanedRepoCleanup() { + return this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].triggerManual(); + } + + async triggerEchoHello(message = "Hello from BullMQ!") { + return this.automations[QUEUE_NAMES.ECHO_HELLO].triggerManual(message); + } + + /** + * Get queue statistics + */ + async getQueueStats(queueName) { + const queue = this.queues[queueName]; + if (!queue) { + throw new Error(`Queue ${queueName} not found`); + } + + const [waiting, active, completed, failed, delayed] = await Promise.all([ + queue.getWaiting(), + queue.getActive(), + queue.getCompleted(), + queue.getFailed(), + queue.getDelayed(), + ]); + + return { + waiting: waiting.length, + active: active.length, + completed: completed.length, + failed: failed.length, + delayed: delayed.length, + }; + } + + /** + * Get all queue statistics + */ + async getAllQueueStats() { + const stats = {}; + for (const queueName of Object.values(QUEUE_NAMES)) { + stats[queueName] = await this.getQueueStats(queueName); + } + return stats; + } + + /** + * Get recent jobs for a queue + */ + async getRecentJobs(queueName, limit = 10) { + const queue = this.queues[queueName]; + if (!queue) { + throw new Error(`Queue ${queueName} not found`); + } + + const [completed, failed] = await Promise.all([ + queue.getCompleted(0, limit - 1), + queue.getFailed(0, limit - 1), + ]); + + return [...completed, ...failed] + .sort((a, b) => new Date(b.finishedOn) - new Date(a.finishedOn)) + .slice(0, limit); + } + + /** + * Graceful shutdown + */ + async shutdown() { + console.log("๐Ÿ›‘ Shutting down queue manager..."); + + for (const queueName of Object.keys(this.queues)) { + await this.queues[queueName].close(); + await this.workers[queueName].close(); + } + + await redis.quit(); + console.log("โœ… Queue manager shutdown complete"); + } +} + +const queueManager = new QueueManager(); + +module.exports = { queueManager, QUEUE_NAMES }; diff --git a/backend/src/services/automation/orphanedRepoCleanup.js b/backend/src/services/automation/orphanedRepoCleanup.js new file mode 100644 index 0000000..6351823 --- /dev/null +++ b/backend/src/services/automation/orphanedRepoCleanup.js @@ -0,0 +1,114 @@ +const { prisma } = require("./shared/prisma"); + +/** + * Orphaned Repository Cleanup Automation + * Removes repositories with no associated hosts + */ +class OrphanedRepoCleanup { + constructor(queueManager) { + this.queueManager = queueManager; + this.queueName = "orphaned-repo-cleanup"; + } + + /** + * Process orphaned repository cleanup job + */ + async process(job) { + const startTime = Date.now(); + console.log("๐Ÿงน Starting orphaned repository cleanup..."); + + try { + // Find repositories with 0 hosts + const orphanedRepos = await prisma.repositories.findMany({ + where: { + host_repositories: { + none: {}, + }, + }, + include: { + _count: { + select: { + host_repositories: true, + }, + }, + }, + }); + + let deletedCount = 0; + const deletedRepos = []; + + // Delete orphaned repositories + for (const repo of orphanedRepos) { + try { + await prisma.repositories.delete({ + where: { id: repo.id }, + }); + deletedCount++; + deletedRepos.push({ + id: repo.id, + name: repo.name, + url: repo.url, + }); + console.log( + `๐Ÿ—‘๏ธ Deleted orphaned repository: ${repo.name} (${repo.url})`, + ); + } catch (deleteError) { + console.error( + `โŒ Failed to delete repository ${repo.id}:`, + deleteError.message, + ); + } + } + + const executionTime = Date.now() - startTime; + console.log( + `โœ… Orphaned repository cleanup completed in ${executionTime}ms - Deleted ${deletedCount} repositories`, + ); + + return { + success: true, + deletedCount, + deletedRepos, + executionTime, + }; + } catch (error) { + const executionTime = Date.now() - startTime; + console.error( + `โŒ Orphaned repository cleanup failed after ${executionTime}ms:`, + error.message, + ); + throw error; + } + } + + /** + * Schedule recurring orphaned repository cleanup (daily at 2 AM) + */ + async schedule() { + const job = await this.queueManager.queues[this.queueName].add( + "orphaned-repo-cleanup", + {}, + { + repeat: { cron: "0 2 * * *" }, // Daily at 2 AM + jobId: "orphaned-repo-cleanup-recurring", + }, + ); + console.log("โœ… Orphaned repository cleanup scheduled"); + return job; + } + + /** + * Trigger manual orphaned repository cleanup + */ + async triggerManual() { + const job = await this.queueManager.queues[this.queueName].add( + "orphaned-repo-cleanup-manual", + {}, + { priority: 1 }, + ); + console.log("โœ… Manual orphaned repository cleanup triggered"); + return job; + } +} + +module.exports = OrphanedRepoCleanup; diff --git a/backend/src/services/automation/sessionCleanup.js b/backend/src/services/automation/sessionCleanup.js new file mode 100644 index 0000000..1454224 --- /dev/null +++ b/backend/src/services/automation/sessionCleanup.js @@ -0,0 +1,78 @@ +const { prisma } = require("./shared/prisma"); +const { cleanup_expired_sessions } = require("../../utils/session_manager"); + +/** + * Session Cleanup Automation + * Cleans up expired user sessions + */ +class SessionCleanup { + constructor(queueManager) { + this.queueManager = queueManager; + this.queueName = "session-cleanup"; + } + + /** + * Process session cleanup job + */ + async process(job) { + const startTime = Date.now(); + console.log("๐Ÿงน Starting session cleanup..."); + + try { + const result = await prisma.user_sessions.deleteMany({ + where: { + OR: [{ expires_at: { lt: new Date() } }, { is_revoked: true }], + }, + }); + + const executionTime = Date.now() - startTime; + console.log( + `โœ… Session cleanup completed in ${executionTime}ms - Cleaned up ${result.count} expired sessions`, + ); + + return { + success: true, + sessionsCleaned: result.count, + executionTime, + }; + } catch (error) { + const executionTime = Date.now() - startTime; + console.error( + `โŒ Session cleanup failed after ${executionTime}ms:`, + error.message, + ); + throw error; + } + } + + /** + * Schedule recurring session cleanup (every hour) + */ + async schedule() { + const job = await this.queueManager.queues[this.queueName].add( + "session-cleanup", + {}, + { + repeat: { cron: "0 * * * *" }, // Every hour + jobId: "session-cleanup-recurring", + }, + ); + console.log("โœ… Session cleanup scheduled"); + return job; + } + + /** + * Trigger manual session cleanup + */ + async triggerManual() { + const job = await this.queueManager.queues[this.queueName].add( + "session-cleanup-manual", + {}, + { priority: 1 }, + ); + console.log("โœ… Manual session cleanup triggered"); + return job; + } +} + +module.exports = SessionCleanup; diff --git a/backend/src/services/automation/shared/prisma.js b/backend/src/services/automation/shared/prisma.js new file mode 100644 index 0000000..c8518ca --- /dev/null +++ b/backend/src/services/automation/shared/prisma.js @@ -0,0 +1,5 @@ +const { PrismaClient } = require("@prisma/client"); + +const prisma = new PrismaClient(); + +module.exports = { prisma }; diff --git a/backend/src/services/automation/shared/redis.js b/backend/src/services/automation/shared/redis.js new file mode 100644 index 0000000..e4e1f2e --- /dev/null +++ b/backend/src/services/automation/shared/redis.js @@ -0,0 +1,16 @@ +const IORedis = require("ioredis"); + +// Redis connection configuration +const redisConnection = { + host: process.env.REDIS_HOST || "localhost", + port: parseInt(process.env.REDIS_PORT) || 6379, + password: process.env.REDIS_PASSWORD || undefined, + db: parseInt(process.env.REDIS_DB) || 0, + retryDelayOnFailover: 100, + maxRetriesPerRequest: null, // BullMQ requires this to be null +}; + +// Create Redis connection +const redis = new IORedis(redisConnection); + +module.exports = { redis, redisConnection }; diff --git a/backend/src/services/automation/shared/utils.js b/backend/src/services/automation/shared/utils.js new file mode 100644 index 0000000..9eb52b8 --- /dev/null +++ b/backend/src/services/automation/shared/utils.js @@ -0,0 +1,82 @@ +// Common utilities for automation jobs + +/** + * Compare two semantic versions + * @param {string} version1 - First version + * @param {string} version2 - Second version + * @returns {number} - 1 if version1 > version2, -1 if version1 < version2, 0 if equal + */ +function compareVersions(version1, version2) { + const v1parts = version1.split(".").map(Number); + const v2parts = version2.split(".").map(Number); + + const maxLength = Math.max(v1parts.length, v2parts.length); + + for (let i = 0; i < maxLength; i++) { + const v1part = v1parts[i] || 0; + const v2part = v2parts[i] || 0; + + if (v1part > v2part) return 1; + if (v1part < v2part) return -1; + } + + return 0; +} + +/** + * Check public GitHub repository for latest release + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @returns {Promise} - Latest version or null + */ +async function checkPublicRepo(owner, repo) { + try { + const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`; + + let currentVersion = "1.2.7"; // fallback + try { + const packageJson = require("../../../package.json"); + if (packageJson?.version) { + currentVersion = packageJson.version; + } + } catch (packageError) { + console.warn( + "Could not read version from package.json for User-Agent, using fallback:", + packageError.message, + ); + } + + const response = await fetch(httpsRepoUrl, { + method: "GET", + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": `PatchMon-Server/${currentVersion}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + if ( + errorText.includes("rate limit") || + errorText.includes("API rate limit") + ) { + console.log("โš ๏ธ GitHub API rate limit exceeded, skipping update check"); + return null; + } + throw new Error( + `GitHub API error: ${response.status} ${response.statusText}`, + ); + } + + const releaseData = await response.json(); + return releaseData.tag_name.replace("v", ""); + } catch (error) { + console.error("GitHub API error:", error.message); + throw error; + } +} + +module.exports = { + compareVersions, + checkPublicRepo, +}; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6addbf6..c5aed1a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -18,7 +18,7 @@ const Login = lazy(() => import("./pages/Login")); const PackageDetail = lazy(() => import("./pages/PackageDetail")); const Packages = lazy(() => import("./pages/Packages")); const Profile = lazy(() => import("./pages/Profile")); -const Queue = lazy(() => import("./pages/Queue")); +const Automation = lazy(() => import("./pages/Automation")); const Repositories = lazy(() => import("./pages/Repositories")); const RepositoryDetail = lazy(() => import("./pages/RepositoryDetail")); const AlertChannels = lazy(() => import("./pages/settings/AlertChannels")); @@ -137,11 +137,11 @@ function AppRoutes() { } /> - + } diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 009501f..4c6a5d4 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -136,7 +136,7 @@ const Layout = ({ children }) => { ); } - // Add Pro-Action and Queue items (available to all users with inventory access) + // Add Pro-Action and Automation items (available to all users with inventory access) inventoryItems.push( { name: "Pro-Action", @@ -145,8 +145,8 @@ const Layout = ({ children }) => { comingSoon: true, }, { - name: "Queue", - href: "/queue", + name: "Automation", + href: "/automation", icon: List, comingSoon: true, }, @@ -210,7 +210,7 @@ const Layout = ({ children }) => { if (path === "/services") return "Services"; if (path === "/docker") return "Docker"; if (path === "/pro-action") return "Pro-Action"; - if (path === "/queue") return "Queue"; + if (path === "/automation") return "Automation"; if (path === "/users") return "Users"; if (path === "/permissions") return "Permissions"; if (path === "/settings") return "Settings"; @@ -929,10 +929,14 @@ const Layout = ({ children }) => {
- {/* Page title - hidden on dashboard, hosts, repositories, packages, and host details to give more space to search */} - {!["/", "/hosts", "/repositories", "/packages"].includes( - location.pathname, - ) && + {/* Page title - hidden on dashboard, hosts, repositories, packages, automation, and host details to give more space to search */} + {![ + "/", + "/hosts", + "/repositories", + "/packages", + "/automation", + ].includes(location.pathname) && !location.pathname.startsWith("/hosts/") && (

@@ -943,7 +947,7 @@ const Layout = ({ children }) => { {/* Global Search Bar */}
diff --git a/frontend/src/pages/Automation.jsx b/frontend/src/pages/Automation.jsx new file mode 100644 index 0000000..9716664 --- /dev/null +++ b/frontend/src/pages/Automation.jsx @@ -0,0 +1,581 @@ +import { useQuery } from "@tanstack/react-query"; +import { + Activity, + AlertCircle, + ArrowDown, + ArrowUp, + ArrowUpDown, + Bot, + CheckCircle, + Clock, + Play, + RefreshCw, + Settings, + XCircle, + Zap, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import api from "../utils/api"; + +const Automation = () => { + const [activeTab, setActiveTab] = useState("overview"); + const [sortField, setSortField] = useState("nextRunTimestamp"); + const [sortDirection, setSortDirection] = useState("asc"); + + // Fetch automation overview data + const { data: overview, isLoading: overviewLoading } = useQuery({ + queryKey: ["automation-overview"], + queryFn: async () => { + const response = await api.get("/automation/overview"); + return response.data.data; + }, + refetchInterval: 30000, // Refresh every 30 seconds + }); + + // Fetch queue statistics + const { data: queueStats, isLoading: statsLoading } = useQuery({ + queryKey: ["automation-stats"], + queryFn: async () => { + const response = await api.get("/automation/stats"); + return response.data.data; + }, + refetchInterval: 30000, + }); + + // Fetch recent jobs + const { data: recentJobs, isLoading: jobsLoading } = useQuery({ + queryKey: ["automation-jobs"], + queryFn: async () => { + const jobs = await Promise.all([ + api + .get("/automation/jobs/github-update-check?limit=5") + .then((r) => r.data.data || []), + api + .get("/automation/jobs/session-cleanup?limit=5") + .then((r) => r.data.data || []), + ]); + return { + githubUpdate: jobs[0], + sessionCleanup: jobs[1], + }; + }, + refetchInterval: 30000, + }); + + const getStatusIcon = (status) => { + switch (status) { + case "completed": + return ; + case "failed": + return ; + case "active": + return ; + default: + return ; + } + }; + + const getStatusColor = (status) => { + switch (status) { + case "completed": + return "bg-green-100 text-green-800"; + case "failed": + return "bg-red-100 text-red-800"; + case "active": + return "bg-blue-100 text-blue-800"; + default: + return "bg-gray-100 text-gray-800"; + } + }; + + const formatDate = (dateString) => { + if (!dateString) return "N/A"; + return new Date(dateString).toLocaleString(); + }; + + const formatDuration = (ms) => { + if (!ms) return "N/A"; + return `${ms}ms`; + }; + + const getStatusBadge = (status) => { + switch (status) { + case "Success": + return ( + + Success + + ); + case "Failed": + return ( + + Failed + + ); + case "Never run": + return ( + + Never run + + ); + default: + return ( + + {status} + + ); + } + }; + + const getNextRunTime = (schedule, lastRun) => { + if (schedule === "Manual only") return "Manual trigger only"; + if (schedule === "Daily at midnight") { + const now = new Date(); + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + return tomorrow.toLocaleString([], { + hour12: true, + hour: "numeric", + minute: "2-digit", + day: "numeric", + month: "numeric", + year: "numeric", + }); + } + if (schedule === "Daily at 2 AM") { + const now = new Date(); + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(2, 0, 0, 0); + return tomorrow.toLocaleString([], { + hour12: true, + hour: "numeric", + minute: "2-digit", + day: "numeric", + month: "numeric", + year: "numeric", + }); + } + if (schedule === "Every hour") { + const now = new Date(); + const nextHour = new Date(now); + nextHour.setHours(nextHour.getHours() + 1, 0, 0, 0); + return nextHour.toLocaleString([], { + hour12: true, + hour: "numeric", + minute: "2-digit", + day: "numeric", + month: "numeric", + year: "numeric", + }); + } + return "Unknown"; + }; + + const getNextRunTimestamp = (schedule) => { + if (schedule === "Manual only") return Number.MAX_SAFE_INTEGER; // Manual tasks go to bottom + if (schedule === "Daily at midnight") { + const now = new Date(); + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + return tomorrow.getTime(); + } + if (schedule === "Daily at 2 AM") { + const now = new Date(); + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(2, 0, 0, 0); + return tomorrow.getTime(); + } + if (schedule === "Every hour") { + const now = new Date(); + const nextHour = new Date(now); + nextHour.setHours(nextHour.getHours() + 1, 0, 0, 0); + return nextHour.getTime(); + } + return Number.MAX_SAFE_INTEGER; // Unknown schedules go to bottom + }; + + const triggerManualJob = async (jobType, data = {}) => { + try { + let endpoint; + + if (jobType === "github") { + endpoint = "/automation/trigger/github-update"; + } else if (jobType === "sessions") { + endpoint = "/automation/trigger/session-cleanup"; + } else if (jobType === "echo") { + endpoint = "/automation/trigger/echo-hello"; + } else if (jobType === "orphaned-repos") { + endpoint = "/automation/trigger/orphaned-repo-cleanup"; + } + + const response = await api.post(endpoint, data); + + // Refresh data + window.location.reload(); + } catch (error) { + console.error("Error triggering job:", error); + alert( + "Failed to trigger job: " + + (error.response?.data?.error || error.message), + ); + } + }; + + const handleSort = (field) => { + if (sortField === field) { + setSortDirection(sortDirection === "asc" ? "desc" : "asc"); + } else { + setSortField(field); + setSortDirection("asc"); + } + }; + + const getSortIcon = (field) => { + if (sortField !== field) return ; + return sortDirection === "asc" ? ( + + ) : ( + + ); + }; + + // Sort automations based on current sort settings + const sortedAutomations = overview?.automations + ? [...overview.automations].sort((a, b) => { + let aValue, bValue; + + switch (sortField) { + case "name": + aValue = a.name.toLowerCase(); + bValue = b.name.toLowerCase(); + break; + case "schedule": + aValue = a.schedule.toLowerCase(); + bValue = b.schedule.toLowerCase(); + break; + case "lastRun": + // Convert "Never" to empty string for proper sorting + aValue = a.lastRun === "Never" ? "" : a.lastRun; + bValue = b.lastRun === "Never" ? "" : b.lastRun; + break; + case "lastRunTimestamp": + aValue = a.lastRunTimestamp || 0; + bValue = b.lastRunTimestamp || 0; + break; + case "nextRunTimestamp": + aValue = getNextRunTimestamp(a.schedule); + bValue = getNextRunTimestamp(b.schedule); + break; + case "status": + aValue = a.status.toLowerCase(); + bValue = b.status.toLowerCase(); + break; + default: + aValue = a[sortField]; + bValue = b[sortField]; + } + + if (aValue < bValue) return sortDirection === "asc" ? -1 : 1; + if (aValue > bValue) return sortDirection === "asc" ? 1 : -1; + return 0; + }) + : []; + + const tabs = [{ id: "overview", name: "Overview", icon: Settings }]; + + return ( +
+ {/* Page Header */} +
+
+

+ Automation Management +

+

+ Monitor and manage automated server operations, agent + communications, and patch deployments +

+
+
+ + + +
+
+ + {/* Stats Cards */} +
+ {/* Scheduled Tasks Card */} +
+
+
+ +
+
+

+ Scheduled Tasks +

+

+ {overviewLoading ? "..." : overview?.scheduledTasks || 0} +

+
+
+
+ + {/* Running Tasks Card */} +
+
+
+ +
+
+

+ Running Tasks +

+

+ {overviewLoading ? "..." : overview?.runningTasks || 0} +

+
+
+
+ + {/* Failed Tasks Card */} +
+
+
+ +
+
+

+ Failed Tasks +

+

+ {overviewLoading ? "..." : overview?.failedTasks || 0} +

+
+
+
+ + {/* Total Task Runs Card */} +
+
+
+ +
+
+

+ Total Task Runs +

+

+ {overviewLoading ? "..." : overview?.totalAutomations || 0} +

+
+
+
+
+ + {/* Tabs */} +
+
+ +
+
+ + {/* Tab Content */} + {activeTab === "overview" && ( +
+ {overviewLoading ? ( +
+
+

+ Loading automations... +

+
+ ) : ( +
+ + + + + + + + + + + + + {sortedAutomations.map((automation) => ( + + + + + + + + + ))} + +
+ Run + handleSort("name")} + > +
+ Task + {getSortIcon("name")} +
+
handleSort("schedule")} + > +
+ Frequency + {getSortIcon("schedule")} +
+
handleSort("lastRunTimestamp")} + > +
+ Last Run + {getSortIcon("lastRunTimestamp")} +
+
handleSort("nextRunTimestamp")} + > +
+ Next Run + {getSortIcon("nextRunTimestamp")} +
+
handleSort("status")} + > +
+ Status + {getSortIcon("status")} +
+
+ {automation.schedule !== "Manual only" ? ( + + ) : ( + + )} + +
+
+ {automation.name} +
+
+ {automation.description} +
+
+
+ {automation.schedule} + + {automation.lastRun} + + {getNextRunTime( + automation.schedule, + automation.lastRun, + )} + + {getStatusBadge(automation.status)} +
+
+ )} +
+ )} +
+ ); +}; + +export default Automation; diff --git a/frontend/src/pages/Queue.jsx b/frontend/src/pages/Queue.jsx deleted file mode 100644 index cd09ac4..0000000 --- a/frontend/src/pages/Queue.jsx +++ /dev/null @@ -1,699 +0,0 @@ -import { - Activity, - AlertCircle, - CheckCircle, - Clock, - Download, - Eye, - Filter, - Package, - Pause, - Play, - RefreshCw, - Search, - Server, - XCircle, -} from "lucide-react"; -import { useState } from "react"; - -const Queue = () => { - const [activeTab, setActiveTab] = useState("server"); - const [filterStatus, setFilterStatus] = useState("all"); - const [searchQuery, setSearchQuery] = useState(""); - - // Mock data for demonstration - const serverQueueData = [ - { - id: 1, - type: "Server Update Check", - description: "Check for server updates from GitHub", - status: "running", - priority: "high", - createdAt: "2024-01-15 10:30:00", - estimatedCompletion: "2024-01-15 10:35:00", - progress: 75, - retryCount: 0, - maxRetries: 3, - }, - { - id: 2, - type: "Session Cleanup", - description: "Clear expired login sessions", - status: "pending", - priority: "medium", - createdAt: "2024-01-15 10:25:00", - estimatedCompletion: "2024-01-15 10:40:00", - progress: 0, - retryCount: 0, - maxRetries: 2, - }, - { - id: 3, - type: "Database Optimization", - description: "Optimize database indexes and cleanup old records", - status: "completed", - priority: "low", - createdAt: "2024-01-15 09:00:00", - completedAt: "2024-01-15 09:45:00", - progress: 100, - retryCount: 0, - maxRetries: 1, - }, - { - id: 4, - type: "Backup Creation", - description: "Create system backup", - status: "failed", - priority: "high", - createdAt: "2024-01-15 08:00:00", - errorMessage: "Insufficient disk space", - progress: 45, - retryCount: 2, - maxRetries: 3, - }, - ]; - - const agentQueueData = [ - { - id: 1, - hostname: "web-server-01", - ip: "192.168.1.100", - type: "Agent Update Collection", - description: "Agent v1.2.7 โ†’ v1.2.8", - status: "pending", - priority: "medium", - lastCommunication: "2024-01-15 10:00:00", - nextExpectedCommunication: "2024-01-15 11:00:00", - currentVersion: "1.2.7", - targetVersion: "1.2.8", - retryCount: 0, - maxRetries: 5, - }, - { - id: 2, - hostname: "db-server-02", - ip: "192.168.1.101", - type: "Data Collection", - description: "Collect package and system information", - status: "running", - priority: "high", - lastCommunication: "2024-01-15 10:15:00", - nextExpectedCommunication: "2024-01-15 11:15:00", - currentVersion: "1.2.8", - targetVersion: "1.2.8", - retryCount: 0, - maxRetries: 3, - }, - { - id: 3, - hostname: "app-server-03", - ip: "192.168.1.102", - type: "Agent Update Collection", - description: "Agent v1.2.6 โ†’ v1.2.8", - status: "completed", - priority: "low", - lastCommunication: "2024-01-15 09:30:00", - completedAt: "2024-01-15 09:45:00", - currentVersion: "1.2.8", - targetVersion: "1.2.8", - retryCount: 0, - maxRetries: 5, - }, - { - id: 4, - hostname: "test-server-04", - ip: "192.168.1.103", - type: "Data Collection", - description: "Collect package and system information", - status: "failed", - priority: "medium", - lastCommunication: "2024-01-15 08:00:00", - errorMessage: "Connection timeout", - retryCount: 3, - maxRetries: 3, - }, - ]; - - const patchQueueData = [ - { - id: 1, - hostname: "web-server-01", - ip: "192.168.1.100", - packages: ["nginx", "openssl", "curl"], - type: "Security Updates", - description: "Apply critical security patches", - status: "pending", - priority: "high", - scheduledFor: "2024-01-15 19:00:00", - lastCommunication: "2024-01-15 18:00:00", - nextExpectedCommunication: "2024-01-15 19:00:00", - retryCount: 0, - maxRetries: 3, - }, - { - id: 2, - hostname: "db-server-02", - ip: "192.168.1.101", - packages: ["postgresql", "python3"], - type: "Feature Updates", - description: "Update database and Python packages", - status: "running", - priority: "medium", - scheduledFor: "2024-01-15 20:00:00", - lastCommunication: "2024-01-15 19:15:00", - nextExpectedCommunication: "2024-01-15 20:15:00", - retryCount: 0, - maxRetries: 2, - }, - { - id: 3, - hostname: "app-server-03", - ip: "192.168.1.102", - packages: ["nodejs", "npm"], - type: "Maintenance Updates", - description: "Update Node.js and npm packages", - status: "completed", - priority: "low", - scheduledFor: "2024-01-15 18:30:00", - completedAt: "2024-01-15 18:45:00", - retryCount: 0, - maxRetries: 2, - }, - { - id: 4, - hostname: "test-server-04", - ip: "192.168.1.103", - packages: ["docker", "docker-compose"], - type: "Security Updates", - description: "Update Docker components", - status: "failed", - priority: "high", - scheduledFor: "2024-01-15 17:00:00", - errorMessage: "Package conflicts detected", - retryCount: 2, - maxRetries: 3, - }, - ]; - - const getStatusIcon = (status) => { - switch (status) { - case "running": - return ; - case "completed": - return ; - case "failed": - return ; - case "pending": - return ; - case "paused": - return ; - default: - return ; - } - }; - - const getStatusColor = (status) => { - switch (status) { - case "running": - return "bg-blue-100 text-blue-800"; - case "completed": - return "bg-green-100 text-green-800"; - case "failed": - return "bg-red-100 text-red-800"; - case "pending": - return "bg-yellow-100 text-yellow-800"; - case "paused": - return "bg-gray-100 text-gray-800"; - default: - return "bg-gray-100 text-gray-800"; - } - }; - - const getPriorityColor = (priority) => { - switch (priority) { - case "high": - return "bg-red-100 text-red-800"; - case "medium": - return "bg-yellow-100 text-yellow-800"; - case "low": - return "bg-green-100 text-green-800"; - default: - return "bg-gray-100 text-gray-800"; - } - }; - - const filteredData = (data) => { - let filtered = data; - - if (filterStatus !== "all") { - filtered = filtered.filter((item) => item.status === filterStatus); - } - - if (searchQuery) { - filtered = filtered.filter( - (item) => - item.hostname?.toLowerCase().includes(searchQuery.toLowerCase()) || - item.type?.toLowerCase().includes(searchQuery.toLowerCase()) || - item.description?.toLowerCase().includes(searchQuery.toLowerCase()), - ); - } - - return filtered; - }; - - const tabs = [ - { - id: "server", - name: "Server Queue", - icon: Server, - data: serverQueueData, - count: serverQueueData.length, - }, - { - id: "agent", - name: "Agent Queue", - icon: Download, - data: agentQueueData, - count: agentQueueData.length, - }, - { - id: "patch", - name: "Patch Management", - icon: Package, - data: patchQueueData, - count: patchQueueData.length, - }, - ]; - - const renderServerQueueItem = (item) => ( -
-
-
-
- {getStatusIcon(item.status)} -

- {item.type} -

- - {item.status} - - - {item.priority} - -
-

- {item.description} -

- - {item.status === "running" && ( -
-
- Progress - {item.progress}% -
-
-
-
-
- )} - -
-
- Created: {item.createdAt} -
- {item.status === "running" && ( -
- ETA:{" "} - {item.estimatedCompletion} -
- )} - {item.status === "completed" && ( -
- Completed:{" "} - {item.completedAt} -
- )} - {item.status === "failed" && ( -
- Error: {item.errorMessage} -
- )} -
- - {item.retryCount > 0 && ( -
- Retries: {item.retryCount}/{item.maxRetries} -
- )} -
- -
- {item.status === "running" && ( - - )} - {item.status === "paused" && ( - - )} - {item.status === "failed" && ( - - )} - -
-
-
- ); - - const renderAgentQueueItem = (item) => ( -
-
-
-
- {getStatusIcon(item.status)} -

- {item.hostname} -

- - {item.status} - - - {item.priority} - -
-

- {item.type} -

-

{item.description}

- - {item.type === "Agent Update Collection" && ( -
-
- Version:{" "} - {item.currentVersion} โ†’ {item.targetVersion} -
-
- )} - -
-
- IP: {item.ip} -
-
- Last Comm:{" "} - {item.lastCommunication} -
-
- Next Expected:{" "} - {item.nextExpectedCommunication} -
- {item.status === "completed" && ( -
- Completed:{" "} - {item.completedAt} -
- )} - {item.status === "failed" && ( -
- Error: {item.errorMessage} -
- )} -
- - {item.retryCount > 0 && ( -
- Retries: {item.retryCount}/{item.maxRetries} -
- )} -
- -
- {item.status === "failed" && ( - - )} - -
-
-
- ); - - const renderPatchQueueItem = (item) => ( -
-
-
-
- {getStatusIcon(item.status)} -

- {item.hostname} -

- - {item.status} - - - {item.priority} - -
-

- {item.type} -

-

{item.description}

- -
-
- Packages: -
-
- {item.packages.map((pkg) => ( - - {pkg} - - ))} -
-
- -
-
- IP: {item.ip} -
-
- Scheduled:{" "} - {item.scheduledFor} -
-
- Last Comm:{" "} - {item.lastCommunication} -
-
- Next Expected:{" "} - {item.nextExpectedCommunication} -
- {item.status === "completed" && ( -
- Completed:{" "} - {item.completedAt} -
- )} - {item.status === "failed" && ( -
- Error: {item.errorMessage} -
- )} -
- - {item.retryCount > 0 && ( -
- Retries: {item.retryCount}/{item.maxRetries} -
- )} -
- -
- {item.status === "failed" && ( - - )} - -
-
-
- ); - - const currentTab = tabs.find((tab) => tab.id === activeTab); - const filteredItems = filteredData(currentTab?.data || []); - - return ( -
-
- {/* Header */} -
-

- Queue Management -

-

- Monitor and manage server operations, agent communications, and - patch deployments -

-
- - {/* Tabs */} -
-
- -
-
- - {/* Filters and Search */} -
-
-
- - setSearchQuery(e.target.value)} - className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> -
-
-
- - -
-
- - {/* Queue Items */} -
- {filteredItems.length === 0 ? ( -
- -

- No queue items found -

-

- {searchQuery - ? "Try adjusting your search criteria" - : "No items match the current filters"} -

-
- ) : ( - filteredItems.map((item) => { - switch (activeTab) { - case "server": - return renderServerQueueItem(item); - case "agent": - return renderAgentQueueItem(item); - case "patch": - return renderPatchQueueItem(item); - default: - return null; - } - }) - )} -
-
-
- ); -}; - -export default Queue; diff --git a/package-lock.json b/package-lock.json index 6318e57..c4d5e4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,14 +26,18 @@ "version": "1.2.7", "license": "AGPL-3.0", "dependencies": { + "@bull-board/api": "^6.13.0", + "@bull-board/express": "^6.13.0", "@prisma/client": "^6.1.0", "bcryptjs": "^2.4.3", + "bullmq": "^5.61.0", "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", "express-rate-limit": "^7.5.0", "express-validator": "^7.2.0", "helmet": "^8.0.0", + "ioredis": "^5.8.1", "jsonwebtoken": "^9.0.2", "moment": "^2.30.1", "qrcode": "^1.5.4", @@ -559,6 +563,39 @@ "node": ">=14.21.3" } }, + "node_modules/@bull-board/api": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-6.13.0.tgz", + "integrity": "sha512-GZ0On0VeL5uZVS1x7UdU90F9GV1kdmHa1955hW3Ow1PmslCY/2YwmvnapVdbvCUSVBqluTfbVZsE9X3h79r1kw==", + "license": "MIT", + "dependencies": { + "redis-info": "^3.1.0" + }, + "peerDependencies": { + "@bull-board/ui": "6.13.0" + } + }, + "node_modules/@bull-board/express": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/@bull-board/express/-/express-6.13.0.tgz", + "integrity": "sha512-PAbzD3dplV2NtN8ETs00bp++pBOD+cVb1BEYltXrjyViA2WluDBVKdlh/2wM+sHbYO2TAMNg8bUtKxGNCmxG7w==", + "license": "MIT", + "dependencies": { + "@bull-board/api": "6.13.0", + "@bull-board/ui": "6.13.0", + "ejs": "^3.1.10", + "express": "^4.21.1 || ^5.0.0" + } + }, + "node_modules/@bull-board/ui": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-6.13.0.tgz", + "integrity": "sha512-63I6b3nZnKWI5ok6mw/Tk2rIObuzMTY/tLGyO51p0GW4rAImdXxrK6mT7j4SgEuP2B+tt/8L1jU7sLu8MMcCNw==", + "license": "MIT", + "dependencies": { + "@bull-board/api": "6.13.0" + } + }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -1074,6 +1111,12 @@ "node": ">=18" } }, + "node_modules/@ioredis/commands": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", + "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1233,6 +1276,84 @@ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "license": "MIT" }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1992,7 +2113,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/base32.js": { @@ -2132,6 +2252,33 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/bullmq": { + "version": "5.61.0", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.61.0.tgz", + "integrity": "sha512-khaTjc1JnzaYFl4FrUtsSsqugAW/urRrcZ9Q0ZE+REAw8W+gkHFqxbGlutOu6q7j7n91wibVaaNlOUMdiEvoSQ==", + "license": "MIT", + "dependencies": { + "cron-parser": "^4.9.0", + "ioredis": "^5.4.1", + "msgpackr": "^1.11.2", + "node-abort-controller": "^3.1.1", + "semver": "^7.5.4", + "tslib": "^2.0.0", + "uuid": "^11.1.0" + } + }, + "node_modules/bullmq/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2370,6 +2517,15 @@ "node": ">=6" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", @@ -2563,6 +2719,18 @@ "node": ">= 0.10" } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2667,6 +2835,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2693,6 +2870,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2772,6 +2959,21 @@ "fast-check": "^3.23.1" } }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.227", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.227.tgz", @@ -3080,6 +3282,36 @@ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "license": "MIT" }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3529,6 +3761,30 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ioredis": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.1.tgz", + "integrity": "sha512-Qho8TgIamqEPdgiMadJwzRMW3TudIg6vpg4YONokGDudy4eqRIJtDbVX72pfLBcWxvbn3qm/40TyGUObdW4tLQ==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.4.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3656,6 +3912,23 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jiti": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.0.tgz", @@ -3960,12 +4233,24 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -4050,6 +4335,15 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4180,6 +4474,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -4220,6 +4545,12 @@ "node": ">= 0.6" } }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -4227,6 +4558,21 @@ "devOptional": true, "license": "MIT" }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-releases": { "version": "2.0.21", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", @@ -4532,7 +4878,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -5090,6 +5435,36 @@ "node": ">=8.10.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-info": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redis-info/-/redis-info-3.1.0.tgz", + "integrity": "sha512-ER4L9Sh/vm63DkIE0bkSjxluQlioBiBgf5w1UuldaW/3vPcecdljVDisZhmnCMvsxHNiARTTDDHGg9cGwTfrKg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.11" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5541,6 +5916,12 @@ "node": "*" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",