mirror of
				https://github.com/9technologygroup/patchmon.net.git
				synced 2025-10-31 03:53:51 +00:00 
			
		
		
		
	Improved Agent version checking logic and page with ability to download the binaries from the REPO again
This commit is contained in:
		
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										419
									
								
								backend/src/routes/agentVersionRoutes.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										419
									
								
								backend/src/routes/agentVersionRoutes.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,419 @@ | ||||
| const express = require("express"); | ||||
| const router = express.Router(); | ||||
| const agentVersionService = require("../services/agentVersionService"); | ||||
| const { authenticateToken } = require("../middleware/auth"); | ||||
| const { requirePermission } = require("../middleware/permissions"); | ||||
|  | ||||
| // Test GitHub API connectivity | ||||
| router.get( | ||||
| 	"/test-github", | ||||
| 	authenticateToken, | ||||
| 	requirePermission("can_manage_settings"), | ||||
| 	async (_req, res) => { | ||||
| 		try { | ||||
| 			const axios = require("axios"); | ||||
| 			const response = await axios.get( | ||||
| 				"https://api.github.com/repos/PatchMon/PatchMon-agent/releases", | ||||
| 				{ | ||||
| 					timeout: 10000, | ||||
| 					headers: { | ||||
| 						"User-Agent": "PatchMon-Server/1.0", | ||||
| 						Accept: "application/vnd.github.v3+json", | ||||
| 					}, | ||||
| 				}, | ||||
| 			); | ||||
|  | ||||
| 			res.json({ | ||||
| 				success: true, | ||||
| 				status: response.status, | ||||
| 				releasesFound: response.data.length, | ||||
| 				latestRelease: response.data[0]?.tag_name || "No releases", | ||||
| 				rateLimitRemaining: response.headers["x-ratelimit-remaining"], | ||||
| 				rateLimitLimit: response.headers["x-ratelimit-limit"], | ||||
| 			}); | ||||
| 		} catch (error) { | ||||
| 			console.error("❌ GitHub API test failed:", error.message); | ||||
| 			res.status(500).json({ | ||||
| 				success: false, | ||||
| 				error: error.message, | ||||
| 				status: error.response?.status, | ||||
| 				statusText: error.response?.statusText, | ||||
| 				rateLimitRemaining: error.response?.headers["x-ratelimit-remaining"], | ||||
| 				rateLimitLimit: error.response?.headers["x-ratelimit-limit"], | ||||
| 			}); | ||||
| 		} | ||||
| 	}, | ||||
| ); | ||||
|  | ||||
| // Get current version information | ||||
| router.get("/version", authenticateToken, async (_req, res) => { | ||||
| 	try { | ||||
| 		const versionInfo = await agentVersionService.getVersionInfo(); | ||||
| 		console.log( | ||||
| 			"📊 Version info response:", | ||||
| 			JSON.stringify(versionInfo, null, 2), | ||||
| 		); | ||||
| 		res.json(versionInfo); | ||||
| 	} catch (error) { | ||||
| 		console.error("❌ Failed to get version info:", error.message); | ||||
| 		res.status(500).json({ | ||||
| 			error: "Failed to get version information", | ||||
| 			details: error.message, | ||||
| 			status: "error", | ||||
| 		}); | ||||
| 	} | ||||
| }); | ||||
|  | ||||
| // Refresh current version by executing agent binary | ||||
| router.post( | ||||
| 	"/version/refresh", | ||||
| 	authenticateToken, | ||||
| 	requirePermission("can_manage_settings"), | ||||
| 	async (_req, res) => { | ||||
| 		try { | ||||
| 			console.log("🔄 Refreshing current agent version..."); | ||||
| 			const currentVersion = await agentVersionService.refreshCurrentVersion(); | ||||
| 			console.log("📊 Refreshed current version:", currentVersion); | ||||
| 			res.json({ | ||||
| 				success: true, | ||||
| 				currentVersion: currentVersion, | ||||
| 				message: currentVersion | ||||
| 					? `Current version refreshed: ${currentVersion}` | ||||
| 					: "No agent binary found", | ||||
| 			}); | ||||
| 		} catch (error) { | ||||
| 			console.error("❌ Failed to refresh current version:", error.message); | ||||
| 			res.status(500).json({ | ||||
| 				success: false, | ||||
| 				error: "Failed to refresh current version", | ||||
| 				details: error.message, | ||||
| 			}); | ||||
| 		} | ||||
| 	}, | ||||
| ); | ||||
|  | ||||
| // Download latest update | ||||
| router.post( | ||||
| 	"/version/download", | ||||
| 	authenticateToken, | ||||
| 	requirePermission("can_manage_settings"), | ||||
| 	async (_req, res) => { | ||||
| 		try { | ||||
| 			console.log("🔄 Downloading latest agent update..."); | ||||
| 			const downloadResult = await agentVersionService.downloadLatestUpdate(); | ||||
| 			console.log( | ||||
| 				"📊 Download result:", | ||||
| 				JSON.stringify(downloadResult, null, 2), | ||||
| 			); | ||||
| 			res.json(downloadResult); | ||||
| 		} catch (error) { | ||||
| 			console.error("❌ Failed to download latest update:", error.message); | ||||
| 			res.status(500).json({ | ||||
| 				success: false, | ||||
| 				error: "Failed to download latest update", | ||||
| 				details: error.message, | ||||
| 			}); | ||||
| 		} | ||||
| 	}, | ||||
| ); | ||||
|  | ||||
| // Check for updates | ||||
| router.post( | ||||
| 	"/version/check", | ||||
| 	authenticateToken, | ||||
| 	requirePermission("can_manage_settings"), | ||||
| 	async (_req, res) => { | ||||
| 		try { | ||||
| 			console.log("🔄 Manual update check triggered"); | ||||
| 			const updateInfo = await agentVersionService.checkForUpdates(); | ||||
| 			console.log( | ||||
| 				"📊 Update check result:", | ||||
| 				JSON.stringify(updateInfo, null, 2), | ||||
| 			); | ||||
| 			res.json(updateInfo); | ||||
| 		} catch (error) { | ||||
| 			console.error("❌ Failed to check for updates:", error.message); | ||||
| 			res.status(500).json({ error: "Failed to check for updates" }); | ||||
| 		} | ||||
| 	}, | ||||
| ); | ||||
|  | ||||
| // Get available versions | ||||
| router.get("/versions", authenticateToken, async (_req, res) => { | ||||
| 	try { | ||||
| 		const versions = await agentVersionService.getAvailableVersions(); | ||||
| 		console.log( | ||||
| 			"📦 Available versions response:", | ||||
| 			JSON.stringify(versions, null, 2), | ||||
| 		); | ||||
| 		res.json({ versions }); | ||||
| 	} catch (error) { | ||||
| 		console.error("❌ Failed to get available versions:", error.message); | ||||
| 		res.status(500).json({ error: "Failed to get available versions" }); | ||||
| 	} | ||||
| }); | ||||
|  | ||||
| // Get binary information | ||||
| router.get( | ||||
| 	"/binary/:version/:architecture", | ||||
| 	authenticateToken, | ||||
| 	async (_req, res) => { | ||||
| 		try { | ||||
| 			const { version, architecture } = req.params; | ||||
| 			const binaryInfo = await agentVersionService.getBinaryInfo( | ||||
| 				version, | ||||
| 				architecture, | ||||
| 			); | ||||
| 			res.json(binaryInfo); | ||||
| 		} catch (error) { | ||||
| 			console.error("❌ Failed to get binary info:", error.message); | ||||
| 			res.status(404).json({ error: error.message }); | ||||
| 		} | ||||
| 	}, | ||||
| ); | ||||
|  | ||||
| // Download agent binary | ||||
| router.get( | ||||
| 	"/download/:version/:architecture", | ||||
| 	authenticateToken, | ||||
| 	async (_req, res) => { | ||||
| 		try { | ||||
| 			const { version, architecture } = req.params; | ||||
|  | ||||
| 			// Validate architecture | ||||
| 			if (!agentVersionService.supportedArchitectures.includes(architecture)) { | ||||
| 				return res.status(400).json({ error: "Unsupported architecture" }); | ||||
| 			} | ||||
|  | ||||
| 			await agentVersionService.serveBinary(version, architecture, res); | ||||
| 		} catch (error) { | ||||
| 			console.error("❌ Failed to serve binary:", error.message); | ||||
| 			res.status(500).json({ error: "Failed to serve binary" }); | ||||
| 		} | ||||
| 	}, | ||||
| ); | ||||
|  | ||||
| // Get latest binary for architecture (for agents to query) | ||||
| router.get("/latest/:architecture", async (req, res) => { | ||||
| 	try { | ||||
| 		const { architecture } = req.params; | ||||
|  | ||||
| 		// Validate architecture | ||||
| 		if (!agentVersionService.supportedArchitectures.includes(architecture)) { | ||||
| 			return res.status(400).json({ error: "Unsupported architecture" }); | ||||
| 		} | ||||
|  | ||||
| 		const versionInfo = await agentVersionService.getVersionInfo(); | ||||
|  | ||||
| 		if (!versionInfo.latestVersion) { | ||||
| 			return res.status(404).json({ error: "No latest version available" }); | ||||
| 		} | ||||
|  | ||||
| 		const binaryInfo = await agentVersionService.getBinaryInfo( | ||||
| 			versionInfo.latestVersion, | ||||
| 			architecture, | ||||
| 		); | ||||
|  | ||||
| 		res.json({ | ||||
| 			version: binaryInfo.version, | ||||
| 			architecture: binaryInfo.architecture, | ||||
| 			size: binaryInfo.size, | ||||
| 			hash: binaryInfo.hash, | ||||
| 			downloadUrl: `/api/v1/agent/download/${binaryInfo.version}/${binaryInfo.architecture}`, | ||||
| 		}); | ||||
| 	} catch (error) { | ||||
| 		console.error("❌ Failed to get latest binary info:", error.message); | ||||
| 		res.status(500).json({ error: "Failed to get latest binary information" }); | ||||
| 	} | ||||
| }); | ||||
|  | ||||
| // Push update notification to specific agent | ||||
| router.post( | ||||
| 	"/notify-update/:apiId", | ||||
| 	authenticateToken, | ||||
| 	requirePermission("admin"), | ||||
| 	async (_req, res) => { | ||||
| 		try { | ||||
| 			const { apiId } = req.params; | ||||
| 			const { version, force = false } = req.body; | ||||
|  | ||||
| 			const versionInfo = await agentVersionService.getVersionInfo(); | ||||
| 			const targetVersion = version || versionInfo.latestVersion; | ||||
|  | ||||
| 			if (!targetVersion) { | ||||
| 				return res | ||||
| 					.status(400) | ||||
| 					.json({ error: "No version specified or available" }); | ||||
| 			} | ||||
|  | ||||
| 			// Import WebSocket service | ||||
| 			const { pushUpdateNotification } = require("../services/agentWs"); | ||||
|  | ||||
| 			// Push update notification via WebSocket | ||||
| 			pushUpdateNotification(apiId, { | ||||
| 				version: targetVersion, | ||||
| 				force, | ||||
| 				downloadUrl: `/api/v1/agent/latest/${req.body.architecture || "linux-amd64"}`, | ||||
| 				message: `Update available: ${targetVersion}`, | ||||
| 			}); | ||||
|  | ||||
| 			res.json({ | ||||
| 				success: true, | ||||
| 				message: `Update notification sent to agent ${apiId}`, | ||||
| 				version: targetVersion, | ||||
| 			}); | ||||
| 		} catch (error) { | ||||
| 			console.error("❌ Failed to notify agent update:", error.message); | ||||
| 			res.status(500).json({ error: "Failed to notify agent update" }); | ||||
| 		} | ||||
| 	}, | ||||
| ); | ||||
|  | ||||
| // Push update notification to all agents | ||||
| router.post( | ||||
| 	"/notify-update-all", | ||||
| 	authenticateToken, | ||||
| 	requirePermission("admin"), | ||||
| 	async (_req, res) => { | ||||
| 		try { | ||||
| 			const { version, force = false } = req.body; | ||||
|  | ||||
| 			const versionInfo = await agentVersionService.getVersionInfo(); | ||||
| 			const targetVersion = version || versionInfo.latestVersion; | ||||
|  | ||||
| 			if (!targetVersion) { | ||||
| 				return res | ||||
| 					.status(400) | ||||
| 					.json({ error: "No version specified or available" }); | ||||
| 			} | ||||
|  | ||||
| 			// Import WebSocket service | ||||
| 			const { pushUpdateNotificationToAll } = require("../services/agentWs"); | ||||
|  | ||||
| 			// Push update notification to all connected agents | ||||
| 			const result = await pushUpdateNotificationToAll({ | ||||
| 				version: targetVersion, | ||||
| 				force, | ||||
| 				message: `Update available: ${targetVersion}`, | ||||
| 			}); | ||||
|  | ||||
| 			res.json({ | ||||
| 				success: true, | ||||
| 				message: `Update notification sent to ${result.notifiedCount} agents`, | ||||
| 				version: targetVersion, | ||||
| 				notifiedCount: result.notifiedCount, | ||||
| 				failedCount: result.failedCount, | ||||
| 			}); | ||||
| 		} catch (error) { | ||||
| 			console.error("❌ Failed to notify all agents update:", error.message); | ||||
| 			res.status(500).json({ error: "Failed to notify all agents update" }); | ||||
| 		} | ||||
| 	}, | ||||
| ); | ||||
|  | ||||
| // Check if specific agent needs update and push notification | ||||
| router.post( | ||||
| 	"/check-update/:apiId", | ||||
| 	authenticateToken, | ||||
| 	requirePermission("can_manage_settings"), | ||||
| 	async (_req, res) => { | ||||
| 		try { | ||||
| 			const { apiId } = req.params; | ||||
| 			const { version, force = false } = req.body; | ||||
|  | ||||
| 			if (!version) { | ||||
| 				return res.status(400).json({ | ||||
| 					success: false, | ||||
| 					error: "Agent version is required", | ||||
| 				}); | ||||
| 			} | ||||
|  | ||||
| 			console.log( | ||||
| 				`🔍 Checking update for agent ${apiId} (version: ${version})`, | ||||
| 			); | ||||
| 			const result = await agentVersionService.checkAndPushAgentUpdate( | ||||
| 				apiId, | ||||
| 				version, | ||||
| 				force, | ||||
| 			); | ||||
| 			console.log( | ||||
| 				"📊 Agent update check result:", | ||||
| 				JSON.stringify(result, null, 2), | ||||
| 			); | ||||
|  | ||||
| 			res.json({ | ||||
| 				success: true, | ||||
| 				...result, | ||||
| 			}); | ||||
| 		} catch (error) { | ||||
| 			console.error("❌ Failed to check agent update:", error.message); | ||||
| 			res.status(500).json({ | ||||
| 				success: false, | ||||
| 				error: "Failed to check agent update", | ||||
| 				details: error.message, | ||||
| 			}); | ||||
| 		} | ||||
| 	}, | ||||
| ); | ||||
|  | ||||
| // Push updates to all connected agents | ||||
| router.post( | ||||
| 	"/push-updates-all", | ||||
| 	authenticateToken, | ||||
| 	requirePermission("can_manage_settings"), | ||||
| 	async (_req, res) => { | ||||
| 		try { | ||||
| 			const { force = false } = req.body; | ||||
|  | ||||
| 			console.log(`🔄 Pushing updates to all agents (force: ${force})`); | ||||
| 			const result = await agentVersionService.checkAndPushUpdatesToAll(force); | ||||
| 			console.log("📊 Bulk update result:", JSON.stringify(result, null, 2)); | ||||
|  | ||||
| 			res.json(result); | ||||
| 		} catch (error) { | ||||
| 			console.error("❌ Failed to push updates to all agents:", error.message); | ||||
| 			res.status(500).json({ | ||||
| 				success: false, | ||||
| 				error: "Failed to push updates to all agents", | ||||
| 				details: error.message, | ||||
| 			}); | ||||
| 		} | ||||
| 	}, | ||||
| ); | ||||
|  | ||||
| // Agent reports its version (for automatic update checking) | ||||
| router.post("/report-version", authenticateToken, async (req, res) => { | ||||
| 	try { | ||||
| 		const { apiId, version } = req.body; | ||||
|  | ||||
| 		if (!apiId || !version) { | ||||
| 			return res.status(400).json({ | ||||
| 				success: false, | ||||
| 				error: "API ID and version are required", | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		console.log(`📊 Agent ${apiId} reported version: ${version}`); | ||||
|  | ||||
| 		// Check if agent needs update and push notification if needed | ||||
| 		const updateResult = await agentVersionService.checkAndPushAgentUpdate( | ||||
| 			apiId, | ||||
| 			version, | ||||
| 		); | ||||
|  | ||||
| 		res.json({ | ||||
| 			success: true, | ||||
| 			message: "Version reported successfully", | ||||
| 			updateCheck: updateResult, | ||||
| 		}); | ||||
| 	} catch (error) { | ||||
| 		console.error("❌ Failed to process agent version report:", error.message); | ||||
| 		res.status(500).json({ | ||||
| 			success: false, | ||||
| 			error: "Failed to process version report", | ||||
| 			details: error.message, | ||||
| 		}); | ||||
| 	} | ||||
| }); | ||||
|  | ||||
| module.exports = router; | ||||
| @@ -67,6 +67,7 @@ const gethomepageRoutes = require("./routes/gethomepageRoutes"); | ||||
| const automationRoutes = require("./routes/automationRoutes"); | ||||
| const dockerRoutes = require("./routes/dockerRoutes"); | ||||
| const wsRoutes = require("./routes/wsRoutes"); | ||||
| const agentVersionRoutes = require("./routes/agentVersionRoutes"); | ||||
| const { initSettings } = require("./services/settingsService"); | ||||
| const { queueManager } = require("./services/automation"); | ||||
| const { authenticateToken, requireAdmin } = require("./middleware/auth"); | ||||
| @@ -262,6 +263,7 @@ const PORT = process.env.PORT || 3001; | ||||
| const http = require("node:http"); | ||||
| const server = http.createServer(app); | ||||
| const { init: initAgentWs } = require("./services/agentWs"); | ||||
| const agentVersionService = require("./services/agentVersionService"); | ||||
|  | ||||
| // Trust proxy (needed when behind reverse proxy) and remove X-Powered-By | ||||
| if (process.env.TRUST_PROXY) { | ||||
| @@ -440,6 +442,7 @@ app.use(`/api/${apiVersion}/gethomepage`, gethomepageRoutes); | ||||
| app.use(`/api/${apiVersion}/automation`, automationRoutes); | ||||
| app.use(`/api/${apiVersion}/docker`, dockerRoutes); | ||||
| app.use(`/api/${apiVersion}/ws`, wsRoutes); | ||||
| app.use(`/api/${apiVersion}/agent`, agentVersionRoutes); | ||||
|  | ||||
| // Bull Board - will be populated after queue manager initializes | ||||
| let bullBoardRouter = null; | ||||
| @@ -892,6 +895,7 @@ async function startServer() { | ||||
|  | ||||
| 		// Initialize WS layer with the underlying HTTP server | ||||
| 		initAgentWs(server, prisma); | ||||
| 		await agentVersionService.initialize(); | ||||
|  | ||||
| 		server.listen(PORT, () => { | ||||
| 			if (process.env.ENABLE_LOGGING === "true") { | ||||
|   | ||||
							
								
								
									
										684
									
								
								backend/src/services/agentVersionService.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										684
									
								
								backend/src/services/agentVersionService.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,684 @@ | ||||
| const axios = require("axios"); | ||||
| const fs = require("node:fs").promises; | ||||
| const path = require("node:path"); | ||||
| const { exec } = require("node:child_process"); | ||||
| const { promisify } = require("node:util"); | ||||
| const execAsync = promisify(exec); | ||||
|  | ||||
| // Simple semver comparison function | ||||
| function compareVersions(version1, version2) { | ||||
| 	const v1parts = version1.split(".").map(Number); | ||||
| 	const v2parts = version2.split(".").map(Number); | ||||
|  | ||||
| 	// Ensure both arrays have the same length | ||||
| 	while (v1parts.length < 3) v1parts.push(0); | ||||
| 	while (v2parts.length < 3) v2parts.push(0); | ||||
|  | ||||
| 	for (let i = 0; i < 3; i++) { | ||||
| 		if (v1parts[i] > v2parts[i]) return 1; | ||||
| 		if (v1parts[i] < v2parts[i]) return -1; | ||||
| 	} | ||||
| 	return 0; | ||||
| } | ||||
| const crypto = require("node:crypto"); | ||||
|  | ||||
| class AgentVersionService { | ||||
| 	constructor() { | ||||
| 		this.githubApiUrl = | ||||
| 			"https://api.github.com/repos/PatchMon/PatchMon-agent/releases"; | ||||
| 		this.agentsDir = path.resolve(__dirname, "../../../agents"); | ||||
| 		this.supportedArchitectures = [ | ||||
| 			"linux-amd64", | ||||
| 			"linux-arm64", | ||||
| 			"linux-386", | ||||
| 			"linux-arm", | ||||
| 		]; | ||||
| 		this.currentVersion = null; | ||||
| 		this.latestVersion = null; | ||||
| 		this.lastChecked = null; | ||||
| 		this.checkInterval = 30 * 60 * 1000; // 30 minutes | ||||
| 	} | ||||
|  | ||||
| 	async initialize() { | ||||
| 		try { | ||||
| 			// Ensure agents directory exists | ||||
| 			await fs.mkdir(this.agentsDir, { recursive: true }); | ||||
|  | ||||
| 			console.log("🔍 Testing GitHub API connectivity..."); | ||||
| 			try { | ||||
| 				const testResponse = await axios.get( | ||||
| 					"https://api.github.com/repos/PatchMon/PatchMon-agent/releases", | ||||
| 					{ | ||||
| 						timeout: 5000, | ||||
| 						headers: { | ||||
| 							"User-Agent": "PatchMon-Server/1.0", | ||||
| 							Accept: "application/vnd.github.v3+json", | ||||
| 						}, | ||||
| 					}, | ||||
| 				); | ||||
| 				console.log( | ||||
| 					`✅ GitHub API accessible - found ${testResponse.data.length} releases`, | ||||
| 				); | ||||
| 			} catch (testError) { | ||||
| 				console.error("❌ GitHub API not accessible:", testError.message); | ||||
| 				if (testError.response) { | ||||
| 					console.error( | ||||
| 						"❌ Status:", | ||||
| 						testError.response.status, | ||||
| 						testError.response.statusText, | ||||
| 					); | ||||
| 					if (testError.response.status === 403) { | ||||
| 						console.log("⚠️ GitHub API rate limit exceeded - will retry later"); | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			// Get current agent version by executing the binary | ||||
| 			await this.getCurrentAgentVersion(); | ||||
|  | ||||
| 			// Try to check for updates, but don't fail initialization if GitHub API is unavailable | ||||
| 			try { | ||||
| 				await this.checkForUpdates(); | ||||
| 			} catch (updateError) { | ||||
| 				console.log( | ||||
| 					"⚠️ Failed to check for updates on startup, will retry later:", | ||||
| 					updateError.message, | ||||
| 				); | ||||
| 			} | ||||
|  | ||||
| 			// Set up periodic checking | ||||
| 			setInterval(() => { | ||||
| 				this.checkForUpdates().catch((error) => { | ||||
| 					console.log("⚠️ Periodic update check failed:", error.message); | ||||
| 				}); | ||||
| 			}, this.checkInterval); | ||||
|  | ||||
| 			console.log("✅ Agent Version Service initialized"); | ||||
| 		} catch (error) { | ||||
| 			console.error( | ||||
| 				"❌ Failed to initialize Agent Version Service:", | ||||
| 				error.message, | ||||
| 			); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async getCurrentAgentVersion() { | ||||
| 		try { | ||||
| 			console.log("🔍 Getting current agent version..."); | ||||
|  | ||||
| 			// Try to find the agent binary in agents/ folder only (what gets distributed) | ||||
| 			const possiblePaths = [ | ||||
| 				path.join(this.agentsDir, "patchmon-agent-linux-amd64"), | ||||
| 				path.join(this.agentsDir, "patchmon-agent"), | ||||
| 			]; | ||||
|  | ||||
| 			let agentPath = null; | ||||
| 			for (const testPath of possiblePaths) { | ||||
| 				try { | ||||
| 					await fs.access(testPath); | ||||
| 					agentPath = testPath; | ||||
| 					console.log(`✅ Found agent binary at: ${testPath}`); | ||||
| 					break; | ||||
| 				} catch { | ||||
| 					// Path doesn't exist, continue to next | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			if (!agentPath) { | ||||
| 				console.log( | ||||
| 					"⚠️ No agent binary found in agents/ folder, current version will be unknown", | ||||
| 				); | ||||
| 				console.log("💡 Use the Download Updates button to get agent binaries"); | ||||
| 				this.currentVersion = null; | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			// Execute the agent binary with help flag to get version info | ||||
| 			try { | ||||
| 				const { stdout, stderr } = await execAsync(`${agentPath} --help`, { | ||||
| 					timeout: 10000, | ||||
| 				}); | ||||
|  | ||||
| 				if (stderr) { | ||||
| 					console.log("⚠️ Agent help stderr:", stderr); | ||||
| 				} | ||||
|  | ||||
| 				// Parse version from help output (e.g., "PatchMon Agent v1.3.0") | ||||
| 				const versionMatch = stdout.match( | ||||
| 					/PatchMon Agent v([0-9]+\.[0-9]+\.[0-9]+)/i, | ||||
| 				); | ||||
| 				if (versionMatch) { | ||||
| 					this.currentVersion = versionMatch[1]; | ||||
| 					console.log(`✅ Current agent version: ${this.currentVersion}`); | ||||
| 				} else { | ||||
| 					console.log( | ||||
| 						"⚠️ Could not parse version from agent help output:", | ||||
| 						stdout, | ||||
| 					); | ||||
| 					this.currentVersion = null; | ||||
| 				} | ||||
| 			} catch (execError) { | ||||
| 				console.error("❌ Failed to execute agent binary:", execError.message); | ||||
| 				this.currentVersion = null; | ||||
| 			} | ||||
| 		} catch (error) { | ||||
| 			console.error("❌ Failed to get current agent version:", error.message); | ||||
| 			this.currentVersion = null; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async checkForUpdates() { | ||||
| 		try { | ||||
| 			console.log("🔍 Checking for agent updates..."); | ||||
|  | ||||
| 			const response = await axios.get(this.githubApiUrl, { | ||||
| 				timeout: 10000, | ||||
| 				headers: { | ||||
| 					"User-Agent": "PatchMon-Server/1.0", | ||||
| 					Accept: "application/vnd.github.v3+json", | ||||
| 				}, | ||||
| 			}); | ||||
|  | ||||
| 			console.log(`📡 GitHub API response status: ${response.status}`); | ||||
| 			console.log(`📦 Found ${response.data.length} releases`); | ||||
|  | ||||
| 			const releases = response.data; | ||||
| 			if (releases.length === 0) { | ||||
| 				console.log("ℹ️ No releases found"); | ||||
| 				this.latestVersion = null; | ||||
| 				this.lastChecked = new Date(); | ||||
| 				return { | ||||
| 					latestVersion: null, | ||||
| 					currentVersion: this.currentVersion, | ||||
| 					hasUpdate: false, | ||||
| 					lastChecked: this.lastChecked, | ||||
| 				}; | ||||
| 			} | ||||
|  | ||||
| 			const latestRelease = releases[0]; | ||||
| 			this.latestVersion = latestRelease.tag_name.replace("v", ""); // Remove 'v' prefix | ||||
| 			this.lastChecked = new Date(); | ||||
|  | ||||
| 			console.log(`📦 Latest agent version: ${this.latestVersion}`); | ||||
|  | ||||
| 			// Don't download binaries automatically - only when explicitly requested | ||||
| 			console.log( | ||||
| 				"ℹ️ Skipping automatic binary download - binaries will be downloaded on demand", | ||||
| 			); | ||||
|  | ||||
| 			return { | ||||
| 				latestVersion: this.latestVersion, | ||||
| 				currentVersion: this.currentVersion, | ||||
| 				hasUpdate: this.currentVersion !== this.latestVersion, | ||||
| 				lastChecked: this.lastChecked, | ||||
| 			}; | ||||
| 		} catch (error) { | ||||
| 			console.error("❌ Failed to check for updates:", error.message); | ||||
| 			if (error.response) { | ||||
| 				console.error( | ||||
| 					"❌ GitHub API error:", | ||||
| 					error.response.status, | ||||
| 					error.response.statusText, | ||||
| 				); | ||||
| 				console.error( | ||||
| 					"❌ Rate limit info:", | ||||
| 					error.response.headers["x-ratelimit-remaining"], | ||||
| 					"/", | ||||
| 					error.response.headers["x-ratelimit-limit"], | ||||
| 				); | ||||
| 			} | ||||
| 			throw error; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async downloadBinariesToAgentsFolder(release) { | ||||
| 		try { | ||||
| 			console.log( | ||||
| 				`⬇️ Downloading binaries for version ${release.tag_name} to agents folder...`, | ||||
| 			); | ||||
|  | ||||
| 			for (const arch of this.supportedArchitectures) { | ||||
| 				const assetName = `patchmon-agent-${arch}`; | ||||
| 				const asset = release.assets.find((a) => a.name === assetName); | ||||
|  | ||||
| 				if (!asset) { | ||||
| 					console.warn(`⚠️ Binary not found for architecture: ${arch}`); | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				const binaryPath = path.join(this.agentsDir, assetName); | ||||
|  | ||||
| 				console.log(`⬇️ Downloading ${assetName}...`); | ||||
|  | ||||
| 				const response = await axios.get(asset.browser_download_url, { | ||||
| 					responseType: "stream", | ||||
| 					timeout: 60000, | ||||
| 				}); | ||||
|  | ||||
| 				const writer = require("node:fs").createWriteStream(binaryPath); | ||||
| 				response.data.pipe(writer); | ||||
|  | ||||
| 				await new Promise((resolve, reject) => { | ||||
| 					writer.on("finish", resolve); | ||||
| 					writer.on("error", reject); | ||||
| 				}); | ||||
|  | ||||
| 				// Make executable | ||||
| 				await fs.chmod(binaryPath, "755"); | ||||
|  | ||||
| 				console.log(`✅ Downloaded: ${assetName} to agents folder`); | ||||
| 			} | ||||
| 		} catch (error) { | ||||
| 			console.error( | ||||
| 				"❌ Failed to download binaries to agents folder:", | ||||
| 				error.message, | ||||
| 			); | ||||
| 			throw error; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async downloadBinaryForVersion(version, architecture) { | ||||
| 		try { | ||||
| 			console.log( | ||||
| 				`⬇️ Downloading binary for version ${version} architecture ${architecture}...`, | ||||
| 			); | ||||
|  | ||||
| 			// Get the release info from GitHub | ||||
| 			const response = await axios.get(this.githubApiUrl, { | ||||
| 				timeout: 10000, | ||||
| 				headers: { | ||||
| 					"User-Agent": "PatchMon-Server/1.0", | ||||
| 					Accept: "application/vnd.github.v3+json", | ||||
| 				}, | ||||
| 			}); | ||||
|  | ||||
| 			const releases = response.data; | ||||
| 			const release = releases.find( | ||||
| 				(r) => r.tag_name.replace("v", "") === version, | ||||
| 			); | ||||
|  | ||||
| 			if (!release) { | ||||
| 				throw new Error(`Release ${version} not found`); | ||||
| 			} | ||||
|  | ||||
| 			const assetName = `patchmon-agent-${architecture}`; | ||||
| 			const asset = release.assets.find((a) => a.name === assetName); | ||||
|  | ||||
| 			if (!asset) { | ||||
| 				throw new Error(`Binary not found for architecture: ${architecture}`); | ||||
| 			} | ||||
|  | ||||
| 			const binaryPath = path.join( | ||||
| 				this.agentBinariesDir, | ||||
| 				`${release.tag_name}-${assetName}`, | ||||
| 			); | ||||
|  | ||||
| 			console.log(`⬇️ Downloading ${assetName}...`); | ||||
|  | ||||
| 			const downloadResponse = await axios.get(asset.browser_download_url, { | ||||
| 				responseType: "stream", | ||||
| 				timeout: 60000, | ||||
| 			}); | ||||
|  | ||||
| 			const writer = require("node:fs").createWriteStream(binaryPath); | ||||
| 			downloadResponse.data.pipe(writer); | ||||
|  | ||||
| 			await new Promise((resolve, reject) => { | ||||
| 				writer.on("finish", resolve); | ||||
| 				writer.on("error", reject); | ||||
| 			}); | ||||
|  | ||||
| 			// Make executable | ||||
| 			await fs.chmod(binaryPath, "755"); | ||||
|  | ||||
| 			console.log(`✅ Downloaded: ${assetName}`); | ||||
| 			return binaryPath; | ||||
| 		} catch (error) { | ||||
| 			console.error( | ||||
| 				`❌ Failed to download binary ${version}-${architecture}:`, | ||||
| 				error.message, | ||||
| 			); | ||||
| 			throw error; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async getBinaryPath(version, architecture) { | ||||
| 		const binaryName = `patchmon-agent-${architecture}`; | ||||
| 		const binaryPath = path.join(this.agentsDir, binaryName); | ||||
|  | ||||
| 		try { | ||||
| 			await fs.access(binaryPath); | ||||
| 			return binaryPath; | ||||
| 		} catch { | ||||
| 			throw new Error(`Binary not found: ${binaryName} version ${version}`); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async serveBinary(version, architecture, res) { | ||||
| 		try { | ||||
| 			// Check if binary exists, if not download it | ||||
| 			const binaryPath = await this.getBinaryPath(version, architecture); | ||||
| 			const stats = await fs.stat(binaryPath); | ||||
|  | ||||
| 			res.setHeader("Content-Type", "application/octet-stream"); | ||||
| 			res.setHeader( | ||||
| 				"Content-Disposition", | ||||
| 				`attachment; filename="patchmon-agent-${architecture}"`, | ||||
| 			); | ||||
| 			res.setHeader("Content-Length", stats.size); | ||||
|  | ||||
| 			// Add cache headers | ||||
| 			res.setHeader("Cache-Control", "public, max-age=3600"); | ||||
| 			res.setHeader("ETag", `"${version}-${architecture}"`); | ||||
|  | ||||
| 			const stream = require("node:fs").createReadStream(binaryPath); | ||||
| 			stream.pipe(res); | ||||
| 		} catch (_error) { | ||||
| 			// Binary doesn't exist, try to download it | ||||
| 			console.log( | ||||
| 				`⬇️ Binary not found locally, attempting to download ${version}-${architecture}...`, | ||||
| 			); | ||||
| 			try { | ||||
| 				await this.downloadBinaryForVersion(version, architecture); | ||||
| 				// Retry serving the binary | ||||
| 				const binaryPath = await this.getBinaryPath(version, architecture); | ||||
| 				const stats = await fs.stat(binaryPath); | ||||
|  | ||||
| 				res.setHeader("Content-Type", "application/octet-stream"); | ||||
| 				res.setHeader( | ||||
| 					"Content-Disposition", | ||||
| 					`attachment; filename="patchmon-agent-${architecture}"`, | ||||
| 				); | ||||
| 				res.setHeader("Content-Length", stats.size); | ||||
| 				res.setHeader("Cache-Control", "public, max-age=3600"); | ||||
| 				res.setHeader("ETag", `"${version}-${architecture}"`); | ||||
|  | ||||
| 				const stream = require("node:fs").createReadStream(binaryPath); | ||||
| 				stream.pipe(res); | ||||
| 			} catch (downloadError) { | ||||
| 				console.error( | ||||
| 					`❌ Failed to download binary ${version}-${architecture}:`, | ||||
| 					downloadError.message, | ||||
| 				); | ||||
| 				res | ||||
| 					.status(404) | ||||
| 					.json({ error: "Binary not found and could not be downloaded" }); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async getVersionInfo() { | ||||
| 		let hasUpdate = false; | ||||
| 		let updateStatus = "unknown"; | ||||
|  | ||||
| 		if (this.currentVersion && this.latestVersion) { | ||||
| 			const comparison = compareVersions( | ||||
| 				this.currentVersion, | ||||
| 				this.latestVersion, | ||||
| 			); | ||||
| 			if (comparison < 0) { | ||||
| 				hasUpdate = true; | ||||
| 				updateStatus = "update-available"; | ||||
| 			} else if (comparison > 0) { | ||||
| 				hasUpdate = false; | ||||
| 				updateStatus = "newer-version"; | ||||
| 			} else { | ||||
| 				hasUpdate = false; | ||||
| 				updateStatus = "up-to-date"; | ||||
| 			} | ||||
| 		} else if (this.latestVersion && !this.currentVersion) { | ||||
| 			hasUpdate = true; | ||||
| 			updateStatus = "no-agent"; | ||||
| 		} else if (this.currentVersion && !this.latestVersion) { | ||||
| 			// We have a current version but no latest version (GitHub API unavailable) | ||||
| 			hasUpdate = false; | ||||
| 			updateStatus = "github-unavailable"; | ||||
| 		} else if (!this.currentVersion && !this.latestVersion) { | ||||
| 			updateStatus = "no-data"; | ||||
| 		} | ||||
|  | ||||
| 		return { | ||||
| 			currentVersion: this.currentVersion, | ||||
| 			latestVersion: this.latestVersion, | ||||
| 			hasUpdate: hasUpdate, | ||||
| 			updateStatus: updateStatus, | ||||
| 			lastChecked: this.lastChecked, | ||||
| 			supportedArchitectures: this.supportedArchitectures, | ||||
| 			status: this.latestVersion ? "ready" : "no-releases", | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	async refreshCurrentVersion() { | ||||
| 		await this.getCurrentAgentVersion(); | ||||
| 		return this.currentVersion; | ||||
| 	} | ||||
|  | ||||
| 	async downloadLatestUpdate() { | ||||
| 		try { | ||||
| 			console.log("⬇️ Downloading latest agent update..."); | ||||
|  | ||||
| 			// First check for updates to get the latest release info | ||||
| 			const _updateInfo = await this.checkForUpdates(); | ||||
|  | ||||
| 			if (!this.latestVersion) { | ||||
| 				throw new Error("No latest version available to download"); | ||||
| 			} | ||||
|  | ||||
| 			// Get the release info from GitHub | ||||
| 			const response = await axios.get(this.githubApiUrl, { | ||||
| 				timeout: 10000, | ||||
| 				headers: { | ||||
| 					"User-Agent": "PatchMon-Server/1.0", | ||||
| 					Accept: "application/vnd.github.v3+json", | ||||
| 				}, | ||||
| 			}); | ||||
|  | ||||
| 			const releases = response.data; | ||||
| 			const latestRelease = releases[0]; | ||||
|  | ||||
| 			if (!latestRelease) { | ||||
| 				throw new Error("No releases found"); | ||||
| 			} | ||||
|  | ||||
| 			console.log( | ||||
| 				`⬇️ Downloading binaries for version ${latestRelease.tag_name}...`, | ||||
| 			); | ||||
|  | ||||
| 			// Download binaries for all architectures directly to agents folder | ||||
| 			await this.downloadBinariesToAgentsFolder(latestRelease); | ||||
|  | ||||
| 			console.log("✅ Latest update downloaded successfully"); | ||||
|  | ||||
| 			return { | ||||
| 				success: true, | ||||
| 				version: this.latestVersion, | ||||
| 				downloadedArchitectures: this.supportedArchitectures, | ||||
| 				message: `Successfully downloaded version ${this.latestVersion}`, | ||||
| 			}; | ||||
| 		} catch (error) { | ||||
| 			console.error("❌ Failed to download latest update:", error.message); | ||||
| 			throw error; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async getAvailableVersions() { | ||||
| 		// No local caching - only return latest from GitHub | ||||
| 		if (this.latestVersion) { | ||||
| 			return [this.latestVersion]; | ||||
| 		} | ||||
| 		return []; | ||||
| 	} | ||||
|  | ||||
| 	async getBinaryInfo(version, architecture) { | ||||
| 		try { | ||||
| 			const binaryPath = await this.getBinaryPath(version, architecture); | ||||
| 			const stats = await fs.stat(binaryPath); | ||||
|  | ||||
| 			// Calculate file hash | ||||
| 			const fileBuffer = await fs.readFile(binaryPath); | ||||
| 			const hash = crypto.createHash("sha256").update(fileBuffer).digest("hex"); | ||||
|  | ||||
| 			return { | ||||
| 				version, | ||||
| 				architecture, | ||||
| 				size: stats.size, | ||||
| 				hash, | ||||
| 				lastModified: stats.mtime, | ||||
| 				path: binaryPath, | ||||
| 			}; | ||||
| 		} catch (error) { | ||||
| 			throw new Error(`Failed to get binary info: ${error.message}`); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Check if an agent needs an update and push notification if needed | ||||
| 	 * @param {string} agentApiId - The agent's API ID | ||||
| 	 * @param {string} agentVersion - The agent's current version | ||||
| 	 * @param {boolean} force - Force update regardless of version | ||||
| 	 * @returns {Object} Update check result | ||||
| 	 */ | ||||
| 	async checkAndPushAgentUpdate(agentApiId, agentVersion, force = false) { | ||||
| 		try { | ||||
| 			console.log( | ||||
| 				`🔍 Checking update for agent ${agentApiId} (version: ${agentVersion})`, | ||||
| 			); | ||||
|  | ||||
| 			// Get current server version info | ||||
| 			const versionInfo = await this.getVersionInfo(); | ||||
|  | ||||
| 			if (!versionInfo.latestVersion) { | ||||
| 				console.log(`⚠️ No latest version available for agent ${agentApiId}`); | ||||
| 				return { | ||||
| 					needsUpdate: false, | ||||
| 					reason: "no-latest-version", | ||||
| 					message: "No latest version available on server", | ||||
| 				}; | ||||
| 			} | ||||
|  | ||||
| 			// Compare versions | ||||
| 			const comparison = compareVersions( | ||||
| 				agentVersion, | ||||
| 				versionInfo.latestVersion, | ||||
| 			); | ||||
| 			const needsUpdate = force || comparison < 0; | ||||
|  | ||||
| 			if (needsUpdate) { | ||||
| 				console.log( | ||||
| 					`📤 Agent ${agentApiId} needs update: ${agentVersion} → ${versionInfo.latestVersion}`, | ||||
| 				); | ||||
|  | ||||
| 				// Import agentWs service to push notification | ||||
| 				const { pushUpdateNotification } = require("./agentWs"); | ||||
|  | ||||
| 				const updateInfo = { | ||||
| 					version: versionInfo.latestVersion, | ||||
| 					force: force, | ||||
| 					downloadUrl: `/api/v1/agent/binary/${versionInfo.latestVersion}/linux-amd64`, | ||||
| 					message: force | ||||
| 						? "Force update requested" | ||||
| 						: `Update available: ${versionInfo.latestVersion}`, | ||||
| 				}; | ||||
|  | ||||
| 				const pushed = pushUpdateNotification(agentApiId, updateInfo); | ||||
|  | ||||
| 				if (pushed) { | ||||
| 					console.log(`✅ Update notification pushed to agent ${agentApiId}`); | ||||
| 					return { | ||||
| 						needsUpdate: true, | ||||
| 						reason: force ? "force-update" : "version-outdated", | ||||
| 						message: `Update notification sent: ${agentVersion} → ${versionInfo.latestVersion}`, | ||||
| 						targetVersion: versionInfo.latestVersion, | ||||
| 					}; | ||||
| 				} else { | ||||
| 					console.log( | ||||
| 						`⚠️ Failed to push update notification to agent ${agentApiId} (not connected)`, | ||||
| 					); | ||||
| 					return { | ||||
| 						needsUpdate: true, | ||||
| 						reason: "agent-offline", | ||||
| 						message: "Agent needs update but is not connected", | ||||
| 						targetVersion: versionInfo.latestVersion, | ||||
| 					}; | ||||
| 				} | ||||
| 			} else { | ||||
| 				console.log(`✅ Agent ${agentApiId} is up to date: ${agentVersion}`); | ||||
| 				return { | ||||
| 					needsUpdate: false, | ||||
| 					reason: "up-to-date", | ||||
| 					message: `Agent is up to date: ${agentVersion}`, | ||||
| 				}; | ||||
| 			} | ||||
| 		} catch (error) { | ||||
| 			console.error( | ||||
| 				`❌ Failed to check update for agent ${agentApiId}:`, | ||||
| 				error.message, | ||||
| 			); | ||||
| 			return { | ||||
| 				needsUpdate: false, | ||||
| 				reason: "error", | ||||
| 				message: `Error checking update: ${error.message}`, | ||||
| 			}; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Check and push updates to all connected agents | ||||
| 	 * @param {boolean} force - Force update regardless of version | ||||
| 	 * @returns {Object} Bulk update result | ||||
| 	 */ | ||||
| 	async checkAndPushUpdatesToAll(force = false) { | ||||
| 		try { | ||||
| 			console.log( | ||||
| 				`🔍 Checking updates for all connected agents (force: ${force})`, | ||||
| 			); | ||||
|  | ||||
| 			// Import agentWs service to get connected agents | ||||
| 			const { pushUpdateNotificationToAll } = require("./agentWs"); | ||||
|  | ||||
| 			const versionInfo = await this.getVersionInfo(); | ||||
|  | ||||
| 			if (!versionInfo.latestVersion) { | ||||
| 				return { | ||||
| 					success: false, | ||||
| 					message: "No latest version available on server", | ||||
| 					updatedAgents: 0, | ||||
| 					totalAgents: 0, | ||||
| 				}; | ||||
| 			} | ||||
|  | ||||
| 			const updateInfo = { | ||||
| 				version: versionInfo.latestVersion, | ||||
| 				force: force, | ||||
| 				downloadUrl: `/api/v1/agent/binary/${versionInfo.latestVersion}/linux-amd64`, | ||||
| 				message: force | ||||
| 					? "Force update requested for all agents" | ||||
| 					: `Update available: ${versionInfo.latestVersion}`, | ||||
| 			}; | ||||
|  | ||||
| 			const result = await pushUpdateNotificationToAll(updateInfo); | ||||
|  | ||||
| 			console.log( | ||||
| 				`✅ Bulk update notification sent to ${result.notifiedCount} agents`, | ||||
| 			); | ||||
|  | ||||
| 			return { | ||||
| 				success: true, | ||||
| 				message: `Update notifications sent to ${result.notifiedCount} agents`, | ||||
| 				updatedAgents: result.notifiedCount, | ||||
| 				totalAgents: result.totalAgents, | ||||
| 				targetVersion: versionInfo.latestVersion, | ||||
| 			}; | ||||
| 		} catch (error) { | ||||
| 			console.error("❌ Failed to push updates to all agents:", error.message); | ||||
| 			return { | ||||
| 				success: false, | ||||
| 				message: `Error pushing updates: ${error.message}`, | ||||
| 				updatedAgents: 0, | ||||
| 				totalAgents: 0, | ||||
| 			}; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| module.exports = new AgentVersionService(); | ||||
| @@ -132,6 +132,66 @@ function pushSettingsUpdate(apiId, newInterval) { | ||||
| 	); | ||||
| } | ||||
|  | ||||
| function pushUpdateNotification(apiId, updateInfo) { | ||||
| 	const ws = apiIdToSocket.get(apiId); | ||||
| 	if (ws && ws.readyState === WebSocket.OPEN) { | ||||
| 		safeSend( | ||||
| 			ws, | ||||
| 			JSON.stringify({ | ||||
| 				type: "update_notification", | ||||
| 				version: updateInfo.version, | ||||
| 				force: updateInfo.force || false, | ||||
| 				downloadUrl: updateInfo.downloadUrl, | ||||
| 				message: updateInfo.message, | ||||
| 			}), | ||||
| 		); | ||||
| 		console.log( | ||||
| 			`📤 Pushed update notification to agent ${apiId}: version ${updateInfo.version}`, | ||||
| 		); | ||||
| 		return true; | ||||
| 	} else { | ||||
| 		console.log( | ||||
| 			`⚠️ Agent ${apiId} not connected, cannot push update notification`, | ||||
| 		); | ||||
| 		return false; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| async function pushUpdateNotificationToAll(updateInfo) { | ||||
| 	let notifiedCount = 0; | ||||
| 	let failedCount = 0; | ||||
|  | ||||
| 	for (const [apiId, ws] of apiIdToSocket) { | ||||
| 		if (ws && ws.readyState === WebSocket.OPEN) { | ||||
| 			try { | ||||
| 				safeSend( | ||||
| 					ws, | ||||
| 					JSON.stringify({ | ||||
| 						type: "update_notification", | ||||
| 						version: updateInfo.version, | ||||
| 						force: updateInfo.force || false, | ||||
| 						message: updateInfo.message, | ||||
| 					}), | ||||
| 				); | ||||
| 				notifiedCount++; | ||||
| 				console.log( | ||||
| 					`📤 Pushed update notification to agent ${apiId}: version ${updateInfo.version}`, | ||||
| 				); | ||||
| 			} catch (error) { | ||||
| 				failedCount++; | ||||
| 				console.error(`❌ Failed to notify agent ${apiId}:`, error.message); | ||||
| 			} | ||||
| 		} else { | ||||
| 			failedCount++; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	console.log( | ||||
| 		`📤 Update notification sent to ${notifiedCount} agents, ${failedCount} failed`, | ||||
| 	); | ||||
| 	return { notifiedCount, failedCount }; | ||||
| } | ||||
|  | ||||
| // Notify all subscribers when connection status changes | ||||
| function notifyConnectionChange(apiId, connected) { | ||||
| 	const subscribers = connectionChangeSubscribers.get(apiId); | ||||
| @@ -170,6 +230,8 @@ module.exports = { | ||||
| 	broadcastSettingsUpdate, | ||||
| 	pushReportNow, | ||||
| 	pushSettingsUpdate, | ||||
| 	pushUpdateNotification, | ||||
| 	pushUpdateNotificationToAll, | ||||
| 	// Expose read-only view of connected agents | ||||
| 	getConnectedApiIds: () => Array.from(apiIdToSocket.keys()), | ||||
| 	isConnected: (apiId) => { | ||||
|   | ||||
| @@ -1,387 +1,282 @@ | ||||
| import { useMutation, useQuery } from "@tanstack/react-query"; | ||||
| import { AlertCircle, Code, Download, Plus, Shield, X } from "lucide-react"; | ||||
| import { useId, useState } from "react"; | ||||
| import { agentFileAPI, settingsAPI } from "../../utils/api"; | ||||
| import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | ||||
| import { AlertCircle, CheckCircle, Clock, RefreshCw } from "lucide-react"; | ||||
| import api from "../../utils/api"; | ||||
|  | ||||
| const AgentManagementTab = () => { | ||||
| 	const scriptFileId = useId(); | ||||
| 	const scriptContentId = useId(); | ||||
| 	const [showUploadModal, setShowUploadModal] = useState(false); | ||||
| 	const _queryClient = useQueryClient(); | ||||
|  | ||||
| 	// Agent file queries and mutations | ||||
| 	// Agent version queries | ||||
| 	const { | ||||
| 		data: agentFileInfo, | ||||
| 		isLoading: agentFileLoading, | ||||
| 		error: agentFileError, | ||||
| 		refetch: refetchAgentFile, | ||||
| 		data: versionInfo, | ||||
| 		isLoading: versionLoading, | ||||
| 		error: versionError, | ||||
| 		refetch: refetchVersion, | ||||
| 	} = useQuery({ | ||||
| 		queryKey: ["agentFile"], | ||||
| 		queryFn: () => agentFileAPI.getInfo().then((res) => res.data), | ||||
| 		queryKey: ["agentVersion"], | ||||
| 		queryFn: async () => { | ||||
| 			try { | ||||
| 				const response = await api.get("/agent/version"); | ||||
| 				console.log("🔍 Frontend received version info:", response.data); | ||||
| 				return response.data; | ||||
| 			} catch (error) { | ||||
| 				console.error("Failed to fetch version info:", error); | ||||
| 				throw error; | ||||
| 			} | ||||
| 		}, | ||||
| 		refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes | ||||
| 		enabled: true, // Always enabled | ||||
| 		retry: 3, // Retry failed requests | ||||
| 	}); | ||||
|  | ||||
| 	// Fetch settings for dynamic curl flags | ||||
| 	const { data: settings } = useQuery({ | ||||
| 		queryKey: ["settings"], | ||||
| 		queryFn: () => settingsAPI.get().then((res) => res.data), | ||||
| 	const { | ||||
| 		data: _availableVersions, | ||||
| 		isLoading: _versionsLoading, | ||||
| 		error: _versionsError, | ||||
| 	} = useQuery({ | ||||
| 		queryKey: ["agentVersions"], | ||||
| 		queryFn: async () => { | ||||
| 			try { | ||||
| 				const response = await api.get("/agent/versions"); | ||||
| 				console.log("🔍 Frontend received available versions:", response.data); | ||||
| 				return response.data; | ||||
| 			} catch (error) { | ||||
| 				console.error("Failed to fetch available versions:", error); | ||||
| 				throw error; | ||||
| 			} | ||||
| 		}, | ||||
| 		enabled: true, | ||||
| 		retry: 3, | ||||
| 	}); | ||||
|  | ||||
| 	// Helper function to get curl flags based on settings | ||||
| 	const _getCurlFlags = () => { | ||||
| 		return settings?.ignore_ssl_self_signed ? "-sk" : "-s"; | ||||
| 	}; | ||||
|  | ||||
| 	const uploadAgentMutation = useMutation({ | ||||
| 		mutationFn: (scriptContent) => | ||||
| 			agentFileAPI.upload(scriptContent).then((res) => res.data), | ||||
| 	const checkUpdatesMutation = useMutation({ | ||||
| 		mutationFn: async () => { | ||||
| 			// First check GitHub for updates | ||||
| 			await api.post("/agent/version/check"); | ||||
| 			// Then refresh current agent version detection | ||||
| 			await api.post("/agent/version/refresh"); | ||||
| 		}, | ||||
| 		onSuccess: () => { | ||||
| 			refetchAgentFile(); | ||||
| 			setShowUploadModal(false); | ||||
| 			refetchVersion(); | ||||
| 		}, | ||||
| 		onError: (error) => { | ||||
| 			console.error("Upload agent error:", error); | ||||
| 			console.error("Check updates error:", error); | ||||
| 		}, | ||||
| 	}); | ||||
|  | ||||
| 	const downloadUpdateMutation = useMutation({ | ||||
| 		mutationFn: async () => { | ||||
| 			// Download the latest binaries | ||||
| 			const downloadResult = await api.post("/agent/version/download"); | ||||
| 			// Refresh current agent version detection after download | ||||
| 			await api.post("/agent/version/refresh"); | ||||
| 			// Return the download result for success handling | ||||
| 			return downloadResult; | ||||
| 		}, | ||||
| 		onSuccess: (data) => { | ||||
| 			console.log("Download completed:", data); | ||||
| 			console.log("Download response data:", data.data); | ||||
| 			refetchVersion(); | ||||
| 			// Show success message | ||||
| 			const message = | ||||
| 				data.data?.message || "Agent binaries downloaded successfully"; | ||||
| 			alert(`✅ ${message}`); | ||||
| 		}, | ||||
| 		onError: (error) => { | ||||
| 			console.error("Download update error:", error); | ||||
| 			alert(`❌ Download failed: ${error.message}`); | ||||
| 		}, | ||||
| 	}); | ||||
|  | ||||
| 	const getVersionStatus = () => { | ||||
| 		console.log("🔍 getVersionStatus called with:", { | ||||
| 			versionError, | ||||
| 			versionInfo, | ||||
| 			versionLoading, | ||||
| 		}); | ||||
|  | ||||
| 		if (versionError) { | ||||
| 			console.log("❌ Version error detected:", versionError); | ||||
| 			return { | ||||
| 				status: "error", | ||||
| 				message: "Failed to load version info", | ||||
| 				Icon: AlertCircle, | ||||
| 				color: "text-red-600", | ||||
| 			}; | ||||
| 		} | ||||
|  | ||||
| 		if (!versionInfo || versionLoading) { | ||||
| 			console.log("⏳ Loading state:", { versionInfo, versionLoading }); | ||||
| 			return { | ||||
| 				status: "loading", | ||||
| 				message: "Loading version info...", | ||||
| 				Icon: RefreshCw, | ||||
| 				color: "text-gray-600", | ||||
| 			}; | ||||
| 		} | ||||
|  | ||||
| 		// Use the backend's updateStatus for proper semver comparison | ||||
| 		switch (versionInfo.updateStatus) { | ||||
| 			case "update-available": | ||||
| 				return { | ||||
| 					status: "update-available", | ||||
| 					message: `Update available: ${versionInfo.latestVersion}`, | ||||
| 					Icon: Clock, | ||||
| 					color: "text-yellow-600", | ||||
| 				}; | ||||
| 			case "newer-version": | ||||
| 				return { | ||||
| 					status: "newer-version", | ||||
| 					message: `Newer version running: ${versionInfo.currentVersion}`, | ||||
| 					Icon: CheckCircle, | ||||
| 					color: "text-blue-600", | ||||
| 				}; | ||||
| 			case "up-to-date": | ||||
| 				return { | ||||
| 					status: "up-to-date", | ||||
| 					message: `Up to date: ${versionInfo.latestVersion}`, | ||||
| 					Icon: CheckCircle, | ||||
| 					color: "text-green-600", | ||||
| 				}; | ||||
| 			case "no-agent": | ||||
| 				return { | ||||
| 					status: "no-agent", | ||||
| 					message: "No agent binary found", | ||||
| 					Icon: AlertCircle, | ||||
| 					color: "text-orange-600", | ||||
| 				}; | ||||
| 			case "github-unavailable": | ||||
| 				return { | ||||
| 					status: "github-unavailable", | ||||
| 					message: `Agent running: ${versionInfo.currentVersion} (GitHub API unavailable)`, | ||||
| 					Icon: CheckCircle, | ||||
| 					color: "text-purple-600", | ||||
| 				}; | ||||
| 			case "no-data": | ||||
| 				return { | ||||
| 					status: "no-data", | ||||
| 					message: "No version data available", | ||||
| 					Icon: AlertCircle, | ||||
| 					color: "text-gray-600", | ||||
| 				}; | ||||
| 			default: | ||||
| 				return { | ||||
| 					status: "unknown", | ||||
| 					message: "Version status unknown", | ||||
| 					Icon: AlertCircle, | ||||
| 					color: "text-gray-600", | ||||
| 				}; | ||||
| 		} | ||||
| 	}; | ||||
|  | ||||
| 	const versionStatus = getVersionStatus(); | ||||
| 	const StatusIcon = versionStatus.Icon; | ||||
|  | ||||
| 	return ( | ||||
| 		<div className="space-y-6"> | ||||
| 			{/* Header */} | ||||
| 			<div className="flex items-center justify-between mb-6"> | ||||
| 				<div> | ||||
| 					<div className="flex items-center mb-2"> | ||||
| 						<Code className="h-6 w-6 text-primary-600 mr-3" /> | ||||
| 						<h2 className="text-xl font-semibold text-secondary-900 dark:text-white"> | ||||
| 							Agent File Management | ||||
| 					<h2 className="text-2xl font-bold text-secondary-900 dark:text-white"> | ||||
| 						Agent Version Management | ||||
| 					</h2> | ||||
| 					</div> | ||||
| 					<p className="text-sm text-secondary-500 dark:text-secondary-300"> | ||||
| 						Manage the PatchMon agent script file used for installations and | ||||
| 						updates | ||||
| 					<p className="text-secondary-600 dark:text-secondary-300"> | ||||
| 						Monitor agent versions and download updates | ||||
| 					</p> | ||||
| 				</div> | ||||
| 				<div className="flex items-center gap-2"> | ||||
| 				<div className="flex space-x-3"> | ||||
| 					<button | ||||
| 						type="button" | ||||
| 						onClick={() => { | ||||
| 							const url = "/api/v1/hosts/agent/download"; | ||||
| 							const link = document.createElement("a"); | ||||
| 							link.href = url; | ||||
| 							link.download = "patchmon-agent.sh"; | ||||
| 							document.body.appendChild(link); | ||||
| 							link.click(); | ||||
| 							document.body.removeChild(link); | ||||
| 						}} | ||||
| 						className="btn-outline flex items-center gap-2" | ||||
| 						onClick={() => checkUpdatesMutation.mutate()} | ||||
| 						disabled={checkUpdatesMutation.isPending} | ||||
| 						className="flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50" | ||||
| 					> | ||||
| 						<Download className="h-4 w-4" /> | ||||
| 						Download | ||||
| 					</button> | ||||
| 					<button | ||||
| 						type="button" | ||||
| 						onClick={() => setShowUploadModal(true)} | ||||
| 						className="btn-primary flex items-center gap-2" | ||||
| 					> | ||||
| 						<Plus className="h-4 w-4" /> | ||||
| 						Replace Script | ||||
| 					</button> | ||||
| 				</div> | ||||
| 			</div> | ||||
|  | ||||
| 			{/* Content */} | ||||
| 			{agentFileLoading ? ( | ||||
| 				<div className="flex items-center justify-center py-8"> | ||||
| 					<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div> | ||||
| 				</div> | ||||
| 			) : agentFileError ? ( | ||||
| 				<div className="text-center py-8"> | ||||
| 					<p className="text-red-600 dark:text-red-400"> | ||||
| 						Error loading agent file: {agentFileError.message} | ||||
| 					</p> | ||||
| 				</div> | ||||
| 			) : !agentFileInfo?.exists ? ( | ||||
| 				<div className="text-center py-8"> | ||||
| 					<Code className="h-12 w-12 text-secondary-400 mx-auto mb-4" /> | ||||
| 					<p className="text-secondary-500 dark:text-secondary-300"> | ||||
| 						No agent script found | ||||
| 					</p> | ||||
| 					<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2"> | ||||
| 						Upload an agent script to get started | ||||
| 					</p> | ||||
| 				</div> | ||||
| 			) : ( | ||||
| 				<div className="space-y-6"> | ||||
| 					{/* Agent File Info */} | ||||
| 					<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-6"> | ||||
| 						<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4"> | ||||
| 							Current Agent Script | ||||
| 						</h3> | ||||
| 						<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> | ||||
| 							<div className="flex items-center gap-2"> | ||||
| 								<Code className="h-4 w-4 text-blue-600 dark:text-blue-400" /> | ||||
| 								<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300"> | ||||
| 									Version: | ||||
| 								</span> | ||||
| 								<span className="text-sm text-secondary-900 dark:text-white font-mono"> | ||||
| 									{agentFileInfo.version} | ||||
| 								</span> | ||||
| 							</div> | ||||
| 							<div className="flex items-center gap-2"> | ||||
| 								<Download className="h-4 w-4 text-green-600 dark:text-green-400" /> | ||||
| 								<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300"> | ||||
| 									Size: | ||||
| 								</span> | ||||
| 								<span className="text-sm text-secondary-900 dark:text-white"> | ||||
| 									{agentFileInfo.sizeFormatted} | ||||
| 								</span> | ||||
| 							</div> | ||||
| 							<div className="flex items-center gap-2"> | ||||
| 								<Code className="h-4 w-4 text-yellow-600 dark:text-yellow-400" /> | ||||
| 								<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300"> | ||||
| 									Modified: | ||||
| 								</span> | ||||
| 								<span className="text-sm text-secondary-900 dark:text-white"> | ||||
| 									{new Date(agentFileInfo.lastModified).toLocaleDateString()} | ||||
| 								</span> | ||||
| 							</div> | ||||
| 						</div> | ||||
| 					</div> | ||||
|  | ||||
| 					{/* Usage Instructions */} | ||||
| 					<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4"> | ||||
| 						<div className="flex"> | ||||
| 							<Shield className="h-5 w-5 text-blue-400 dark:text-blue-300" /> | ||||
| 							<div className="ml-3"> | ||||
| 								<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200"> | ||||
| 									Agent Script Usage | ||||
| 								</h3> | ||||
| 								<div className="mt-2 text-sm text-blue-700 dark:text-blue-300"> | ||||
| 									<p className="mb-2">This script is used for:</p> | ||||
| 									<ul className="list-disc list-inside space-y-1"> | ||||
| 										<li>New agent installations via the install script</li> | ||||
| 										<li> | ||||
| 											Agent downloads from the /api/v1/hosts/agent/download | ||||
| 											endpoint | ||||
| 										</li> | ||||
| 										<li>Manual agent deployments and updates</li> | ||||
| 									</ul> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 						</div> | ||||
| 					</div> | ||||
|  | ||||
| 					{/* Uninstall Instructions */} | ||||
| 					<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4"> | ||||
| 						<div className="flex"> | ||||
| 							<Shield className="h-5 w-5 text-red-400 dark:text-red-300" /> | ||||
| 							<div className="ml-3"> | ||||
| 								<h3 className="text-sm font-medium text-red-800 dark:text-red-200"> | ||||
| 									Agent Uninstall Command | ||||
| 								</h3> | ||||
| 								<div className="mt-2 text-sm text-red-700 dark:text-red-300"> | ||||
| 									<p className="mb-3"> | ||||
| 										To completely remove PatchMon from a host: | ||||
| 									</p> | ||||
|  | ||||
| 									{/* Go Agent Uninstall */} | ||||
| 									<div className="mb-3"> | ||||
| 										<div className="space-y-2"> | ||||
| 											<div className="flex items-center gap-2"> | ||||
| 												<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1"> | ||||
| 													sudo patchmon-agent uninstall | ||||
| 												</div> | ||||
| 												<button | ||||
| 													type="button" | ||||
| 													onClick={() => { | ||||
| 														navigator.clipboard.writeText( | ||||
| 															"sudo patchmon-agent uninstall", | ||||
| 														); | ||||
| 													}} | ||||
| 													className="px-2 py-1 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors" | ||||
| 												> | ||||
| 													Copy | ||||
| 												</button> | ||||
| 											</div> | ||||
| 											<div className="text-xs text-red-600 dark:text-red-400"> | ||||
| 												Options: <code>--remove-config</code>,{" "} | ||||
| 												<code>--remove-logs</code>, <code>--remove-all</code>,{" "} | ||||
| 												<code>--force</code> | ||||
| 											</div> | ||||
| 										</div> | ||||
| 									</div> | ||||
|  | ||||
| 									<p className="mt-2 text-xs"> | ||||
| 										⚠️ This command will remove all PatchMon files, | ||||
| 										configuration, and crontab entries | ||||
| 									</p> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			)} | ||||
|  | ||||
| 			{/* Agent Upload Modal */} | ||||
| 			{showUploadModal && ( | ||||
| 				<AgentUploadModal | ||||
| 					isOpen={showUploadModal} | ||||
| 					onClose={() => setShowUploadModal(false)} | ||||
| 					onSubmit={uploadAgentMutation.mutate} | ||||
| 					isLoading={uploadAgentMutation.isPending} | ||||
| 					error={uploadAgentMutation.error} | ||||
| 					scriptFileId={scriptFileId} | ||||
| 					scriptContentId={scriptContentId} | ||||
| 						<RefreshCw | ||||
| 							className={`h-4 w-4 mr-2 ${checkUpdatesMutation.isPending ? "animate-spin" : ""}`} | ||||
| 						/> | ||||
| 			)} | ||||
| 						Check Updates | ||||
| 					</button> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
| // Agent Upload Modal Component | ||||
| const AgentUploadModal = ({ | ||||
| 	isOpen, | ||||
| 	onClose, | ||||
| 	onSubmit, | ||||
| 	isLoading, | ||||
| 	error, | ||||
| 	scriptFileId, | ||||
| 	scriptContentId, | ||||
| }) => { | ||||
| 	const [scriptContent, setScriptContent] = useState(""); | ||||
| 	const [uploadError, setUploadError] = useState(""); | ||||
|  | ||||
| 	const handleSubmit = (e) => { | ||||
| 		e.preventDefault(); | ||||
| 		setUploadError(""); | ||||
|  | ||||
| 		if (!scriptContent.trim()) { | ||||
| 			setUploadError("Script content is required"); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		if (!scriptContent.trim().startsWith("#!/")) { | ||||
| 			setUploadError( | ||||
| 				"Script must start with a shebang (#!/bin/bash or #!/bin/sh)", | ||||
| 			); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		onSubmit(scriptContent); | ||||
| 	}; | ||||
|  | ||||
| 	const handleFileUpload = (e) => { | ||||
| 		const file = e.target.files[0]; | ||||
| 		if (file) { | ||||
| 			const reader = new FileReader(); | ||||
| 			reader.onload = (event) => { | ||||
| 				setScriptContent(event.target.result); | ||||
| 				setUploadError(""); | ||||
| 			}; | ||||
| 			reader.readAsText(file); | ||||
| 		} | ||||
| 	}; | ||||
|  | ||||
| 	if (!isOpen) return null; | ||||
|  | ||||
| 	return ( | ||||
| 		<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> | ||||
| 			<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto"> | ||||
| 				<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600"> | ||||
| 			{/* Download Updates Button */} | ||||
| 			<div className="bg-white dark:bg-secondary-800 rounded-lg shadow p-6 border border-secondary-200 dark:border-secondary-600"> | ||||
| 				<div className="flex items-center justify-between"> | ||||
| 						<h3 className="text-lg font-medium text-secondary-900 dark:text-white"> | ||||
| 							Replace Agent Script | ||||
| 					<div> | ||||
| 						<h3 className="text-lg font-semibold text-secondary-900 dark:text-white"> | ||||
| 							{versionInfo?.currentVersion | ||||
| 								? "Download Agent Updates" | ||||
| 								: "Download Agent Binaries"} | ||||
| 						</h3> | ||||
| 						<p className="text-secondary-600 dark:text-secondary-300"> | ||||
| 							{versionInfo?.currentVersion | ||||
| 								? "Download the latest agent binaries from GitHub" | ||||
| 								: "No agent binaries found. Download from GitHub to get started."} | ||||
| 						</p> | ||||
| 					</div> | ||||
| 					<button | ||||
| 						type="button" | ||||
| 							onClick={onClose} | ||||
| 							className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300" | ||||
| 						onClick={() => downloadUpdateMutation.mutate()} | ||||
| 						disabled={downloadUpdateMutation.isPending} | ||||
| 						className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50" | ||||
| 					> | ||||
| 							<X className="h-5 w-5" /> | ||||
| 						<RefreshCw | ||||
| 							className={`h-4 w-4 mr-2 ${downloadUpdateMutation.isPending ? "animate-spin" : ""}`} | ||||
| 						/> | ||||
| 						{downloadUpdateMutation.isPending | ||||
| 							? "Downloading..." | ||||
| 							: versionInfo?.currentVersion | ||||
| 								? "Download Updates" | ||||
| 								: "Download Agent Binaries"} | ||||
| 					</button> | ||||
| 				</div> | ||||
| 			</div> | ||||
|  | ||||
| 				<form onSubmit={handleSubmit} className="px-6 py-4"> | ||||
| 					<div className="space-y-4"> | ||||
| 						<div> | ||||
| 							<label | ||||
| 								htmlFor={scriptFileId} | ||||
| 								className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2" | ||||
| 							> | ||||
| 								Upload Script File | ||||
| 							</label> | ||||
| 							<input | ||||
| 								id={scriptFileId} | ||||
| 								type="file" | ||||
| 								accept=".sh" | ||||
| 								onChange={handleFileUpload} | ||||
| 								className="block w-full text-sm text-secondary-500 dark:text-secondary-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900 dark:file:text-primary-200" | ||||
| 							/> | ||||
| 							<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400"> | ||||
| 								Select a .sh file to upload, or paste the script content below | ||||
| 							</p> | ||||
| 			{/* Version Status Card */} | ||||
| 			<div className="bg-white dark:bg-secondary-800 rounded-lg shadow p-6 border border-secondary-200 dark:border-secondary-600"> | ||||
| 				<div className="flex items-center justify-between mb-4"> | ||||
| 					<h3 className="text-lg font-semibold text-secondary-900 dark:text-white"> | ||||
| 						Agent Version Status | ||||
| 					</h3> | ||||
| 					<div className="flex items-center space-x-2"> | ||||
| 						{StatusIcon && ( | ||||
| 							<StatusIcon className={`h-5 w-5 ${versionStatus.color}`} /> | ||||
| 						)} | ||||
| 						<span className={`text-sm font-medium ${versionStatus.color}`}> | ||||
| 							{versionStatus.message} | ||||
| 						</span> | ||||
| 					</div> | ||||
| 				</div> | ||||
|  | ||||
| 				{versionInfo && ( | ||||
| 					<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm"> | ||||
| 						<div> | ||||
| 							<label | ||||
| 								htmlFor={scriptContentId} | ||||
| 								className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2" | ||||
| 							> | ||||
| 								Script Content * | ||||
| 							</label> | ||||
| 							<textarea | ||||
| 								id={scriptContentId} | ||||
| 								value={scriptContent} | ||||
| 								onChange={(e) => { | ||||
| 									setScriptContent(e.target.value); | ||||
| 									setUploadError(""); | ||||
| 								}} | ||||
| 								rows={15} | ||||
| 								className="block w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm" | ||||
| 								placeholder="#!/bin/bash

# PatchMon Agent Script
VERSION="1.0.0"

# Your script content here..." | ||||
| 							/> | ||||
| 							<span className="text-secondary-500 dark:text-secondary-400"> | ||||
| 								Current Version: | ||||
| 							</span> | ||||
| 							<span className="ml-2 font-medium text-secondary-900 dark:text-white"> | ||||
| 								{versionInfo.currentVersion || "Unknown"} | ||||
| 							</span> | ||||
| 						</div> | ||||
| 						<div> | ||||
| 							<span className="text-secondary-500 dark:text-secondary-400"> | ||||
| 								Latest Version: | ||||
| 							</span> | ||||
| 							<span className="ml-2 font-medium text-secondary-900 dark:text-white"> | ||||
| 								{versionInfo.latestVersion || "Unknown"} | ||||
| 							</span> | ||||
| 						</div> | ||||
| 						<div> | ||||
| 							<span className="text-secondary-500 dark:text-secondary-400"> | ||||
| 								Last Checked: | ||||
| 							</span> | ||||
| 							<span className="ml-2 font-medium text-secondary-900 dark:text-white"> | ||||
| 								{versionInfo.lastChecked | ||||
| 									? new Date(versionInfo.lastChecked).toLocaleString() | ||||
| 									: "Never"} | ||||
| 							</span> | ||||
| 						</div> | ||||
|  | ||||
| 						{(uploadError || error) && ( | ||||
| 							<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3"> | ||||
| 								<p className="text-sm text-red-800 dark:text-red-200"> | ||||
| 									{uploadError || | ||||
| 										error?.response?.data?.error || | ||||
| 										error?.message} | ||||
| 								</p> | ||||
| 					</div> | ||||
| 				)} | ||||
|  | ||||
| 						<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3"> | ||||
| 							<div className="flex"> | ||||
| 								<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mr-2 mt-0.5" /> | ||||
| 								<div className="text-sm text-yellow-800 dark:text-yellow-200"> | ||||
| 									<p className="font-medium">Important:</p> | ||||
| 									<ul className="mt-1 list-disc list-inside space-y-1"> | ||||
| 										<li>This will replace the current agent script file</li> | ||||
| 										<li>A backup will be created automatically</li> | ||||
| 										<li>All new installations will use this script</li> | ||||
| 										<li> | ||||
| 											Existing agents will download this version on their next | ||||
| 											update | ||||
| 										</li> | ||||
| 									</ul> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 						</div> | ||||
| 					</div> | ||||
|  | ||||
| 					<div className="flex justify-end gap-3 mt-6"> | ||||
| 						<button type="button" onClick={onClose} className="btn-outline"> | ||||
| 							Cancel | ||||
| 						</button> | ||||
| 						<button | ||||
| 							type="submit" | ||||
| 							disabled={isLoading || !scriptContent.trim()} | ||||
| 							className="btn-primary" | ||||
| 						> | ||||
| 							{isLoading ? "Uploading..." : "Replace Script"} | ||||
| 						</button> | ||||
| 					</div> | ||||
| 				</form> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user