mirror of
				https://github.com/9technologygroup/patchmon.net.git
				synced 2025-10-31 20:13:50 +00:00 
			
		
		
		
	Compare commits
	
		
			62 Commits
		
	
	
		
			v1.3.0
			...
			renovate/e
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | faec636400 | ||
|  | 1e75f2b1fe | ||
|  | 79317b0052 | ||
|  | 77a945a5b6 | ||
|  | 276d910e83 | ||
|  | dae536e96b | ||
|  | 8361caabe8 | ||
|  | f6d23e45b2 | ||
|  | aba0f5cb6b | ||
|  | 2ec2b3992c | ||
|  | f85721b292 | ||
|  | 1d2c003830 | ||
|  | 2975da0f69 | ||
|  | 93760d03e1 | ||
|  | 43fb54a683 | ||
|  | e9368d1a95 | ||
|  | 3ce8c02a31 | ||
|  | ac420901a6 | ||
|  | eb0218bdcb | ||
|  | 1f6f58360f | ||
|  | 746451c296 | ||
|  | 285e4c59ee | ||
|  | 9050595b7c | ||
|  | cc46940b0c | ||
|  | 203a065479 | ||
|  | 8864de6c15 | ||
|  | 96aedbe761 | ||
|  | 3df2057f7e | ||
|  | 42f4e58bb4 | ||
|  | 12eef22912 | ||
|  | c2121e3995 | ||
|  | 6792f96af9 | ||
|  | 1e617c8bb8 | ||
|  | a76c5b8963 | ||
|  | 212b24b1c8 | ||
|  | 9fc3f4f9d1 | ||
|  | 3029278742 | ||
|  | e4d6c1205c | ||
|  | 0f5272d12a | ||
|  | 5776d32e71 | ||
|  | a11ff842eb | ||
|  | 48ce1951de | ||
|  | 9705e24b83 | ||
|  | 933c7a067e | ||
|  | 68f10c6c43 | ||
|  | 4b6f19c28e | ||
|  | ae6afb0ef4 | ||
|  | 61523c9a44 | ||
|  | 3f9a5576ac | ||
|  | e2dd7acca5 | ||
|  | 1c3b01f13c | ||
|  | 2c5a35b6c2 | ||
|  | f42c53d34b | ||
|  | 95800e6d76 | ||
|  | 8d372411be | ||
|  | de449c547f | ||
|  | cd03f0e66a | ||
|  | a8bd09be89 | ||
|  | deb6bed1a6 | ||
|  | 3ae8422487 | ||
|  | c98203a997 | ||
|  | 37c8f5fa76 | 
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							| @@ -356,6 +356,7 @@ api_version: "v1" | |||||||
| credentials_file: "/etc/patchmon/credentials.yml" | credentials_file: "/etc/patchmon/credentials.yml" | ||||||
| log_file: "/etc/patchmon/logs/patchmon-agent.log" | log_file: "/etc/patchmon/logs/patchmon-agent.log" | ||||||
| log_level: "info" | log_level: "info" | ||||||
|  | skip_ssl_verify: ${SKIP_SSL_VERIFY:-false} | ||||||
| EOF | EOF | ||||||
|  |  | ||||||
| # Create credentials file | # Create credentials file | ||||||
|   | |||||||
| @@ -1,23 +1,36 @@ | |||||||
| # Database Configuration | # Database Configuration | ||||||
| DATABASE_URL="postgresql://patchmon_user:p@tchm0n_p@55@localhost:5432/patchmon_db" | DATABASE_URL="postgresql://patchmon_user:your-password-here@localhost:5432/patchmon_db" | ||||||
| PM_DB_CONN_MAX_ATTEMPTS=30 | PM_DB_CONN_MAX_ATTEMPTS=30 | ||||||
| PM_DB_CONN_WAIT_INTERVAL=2 | PM_DB_CONN_WAIT_INTERVAL=2 | ||||||
|  |  | ||||||
| # Redis Configuration | # Database Connection Pool Configuration (Prisma) | ||||||
| REDIS_HOST=localhost | DB_CONNECTION_LIMIT=30       # Maximum connections per instance (default: 30) | ||||||
| REDIS_PORT=6379 | DB_POOL_TIMEOUT=20          # Seconds to wait for available connection (default: 20) | ||||||
| REDIS_USER=your-redis-username-here | DB_CONNECT_TIMEOUT=10       # Seconds to wait for initial connection (default: 10) | ||||||
| REDIS_PASSWORD=your-redis-password-here | DB_IDLE_TIMEOUT=300         # Seconds before closing idle connections (default: 300) | ||||||
| REDIS_DB=0 | DB_MAX_LIFETIME=1800        # Maximum lifetime of a connection in seconds (default: 1800) | ||||||
|  |  | ||||||
|  | # JWT Configuration | ||||||
|  | JWT_SECRET=your-secure-random-secret-key-change-this-in-production | ||||||
|  | JWT_EXPIRES_IN=1h | ||||||
|  | JWT_REFRESH_EXPIRES_IN=7d | ||||||
|  |  | ||||||
| # Server Configuration | # Server Configuration | ||||||
| PORT=3001 | PORT=3001 | ||||||
| NODE_ENV=development | NODE_ENV=production | ||||||
|  |  | ||||||
| # API Configuration | # API Configuration | ||||||
| API_VERSION=v1 | API_VERSION=v1 | ||||||
|  |  | ||||||
|  | # CORS Configuration | ||||||
| CORS_ORIGIN=http://localhost:3000 | CORS_ORIGIN=http://localhost:3000 | ||||||
|  |  | ||||||
|  | # Session Configuration | ||||||
|  | SESSION_INACTIVITY_TIMEOUT_MINUTES=30 | ||||||
|  |  | ||||||
|  | # User Configuration | ||||||
|  | DEFAULT_USER_ROLE=user | ||||||
|  |  | ||||||
| # Rate Limiting (times in milliseconds) | # Rate Limiting (times in milliseconds) | ||||||
| RATE_LIMIT_WINDOW_MS=900000 | RATE_LIMIT_WINDOW_MS=900000 | ||||||
| RATE_LIMIT_MAX=5000 | RATE_LIMIT_MAX=5000 | ||||||
| @@ -26,20 +39,18 @@ AUTH_RATE_LIMIT_MAX=500 | |||||||
| AGENT_RATE_LIMIT_WINDOW_MS=60000 | AGENT_RATE_LIMIT_WINDOW_MS=60000 | ||||||
| AGENT_RATE_LIMIT_MAX=1000 | AGENT_RATE_LIMIT_MAX=1000 | ||||||
|  |  | ||||||
|  | # Redis Configuration | ||||||
|  | REDIS_HOST=localhost | ||||||
|  | REDIS_PORT=6379 | ||||||
|  | REDIS_USER=your-redis-username-here | ||||||
|  | REDIS_PASSWORD=your-redis-password-here | ||||||
|  | REDIS_DB=0 | ||||||
|  |  | ||||||
| # Logging | # Logging | ||||||
| LOG_LEVEL=info | LOG_LEVEL=info | ||||||
| ENABLE_LOGGING=true | ENABLE_LOGGING=true | ||||||
|  |  | ||||||
| # User Registration | # TFA Configuration (optional - used if TFA is enabled) | ||||||
| DEFAULT_USER_ROLE=user |  | ||||||
|  |  | ||||||
| # JWT Configuration |  | ||||||
| JWT_SECRET=your-secure-random-secret-key-change-this-in-production |  | ||||||
| JWT_EXPIRES_IN=1h |  | ||||||
| JWT_REFRESH_EXPIRES_IN=7d |  | ||||||
| SESSION_INACTIVITY_TIMEOUT_MINUTES=30 |  | ||||||
|  |  | ||||||
| # TFA Configuration |  | ||||||
| TFA_REMEMBER_ME_EXPIRES_IN=30d | TFA_REMEMBER_ME_EXPIRES_IN=30d | ||||||
| TFA_MAX_REMEMBER_SESSIONS=5 | TFA_MAX_REMEMBER_SESSIONS=5 | ||||||
| TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=3 | TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=3 | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| { | { | ||||||
| 	"name": "patchmon-backend", | 	"name": "patchmon-backend", | ||||||
| 	"version": "1.3.0", | 	"version": "1.3.1", | ||||||
| 	"description": "Backend API for Linux Patch Monitoring System", | 	"description": "Backend API for Linux Patch Monitoring System", | ||||||
| 	"license": "AGPL-3.0", | 	"license": "AGPL-3.0", | ||||||
| 	"main": "src/server.js", | 	"main": "src/server.js", | ||||||
| @@ -24,7 +24,7 @@ | |||||||
| 		"cors": "^2.8.5", | 		"cors": "^2.8.5", | ||||||
| 		"dotenv": "^16.4.7", | 		"dotenv": "^16.4.7", | ||||||
| 		"express": "^4.21.2", | 		"express": "^4.21.2", | ||||||
| 		"express-rate-limit": "^7.5.0", | 		"express-rate-limit": "^8.0.0", | ||||||
| 		"express-validator": "^7.2.0", | 		"express-validator": "^7.2.0", | ||||||
| 		"helmet": "^8.0.0", | 		"helmet": "^8.0.0", | ||||||
| 		"ioredis": "^5.8.1", | 		"ioredis": "^5.8.1", | ||||||
|   | |||||||
| @@ -0,0 +1,4 @@ | |||||||
|  | -- AlterTable | ||||||
|  | -- Add color_theme field to settings table for customizable app theming | ||||||
|  | ALTER TABLE "settings" ADD COLUMN "color_theme" TEXT NOT NULL DEFAULT 'default'; | ||||||
|  |  | ||||||
| @@ -0,0 +1,14 @@ | |||||||
|  | -- AddMetricsTelemetry | ||||||
|  | -- Add anonymous metrics and telemetry fields to settings table | ||||||
|  |  | ||||||
|  | -- Add metrics fields to settings table | ||||||
|  | ALTER TABLE "settings" ADD COLUMN "metrics_enabled" BOOLEAN NOT NULL DEFAULT true; | ||||||
|  | ALTER TABLE "settings" ADD COLUMN "metrics_anonymous_id" TEXT; | ||||||
|  | ALTER TABLE "settings" ADD COLUMN "metrics_last_sent" TIMESTAMP(3); | ||||||
|  |  | ||||||
|  | -- Generate UUID for existing records (if any exist) | ||||||
|  | -- This will use PostgreSQL's gen_random_uuid() function | ||||||
|  | UPDATE "settings"  | ||||||
|  | SET "metrics_anonymous_id" = gen_random_uuid()::text  | ||||||
|  | WHERE "metrics_anonymous_id" IS NULL; | ||||||
|  |  | ||||||
| @@ -191,6 +191,10 @@ model settings { | |||||||
|   logo_dark              String?   @default("/assets/logo_dark.png") |   logo_dark              String?   @default("/assets/logo_dark.png") | ||||||
|   logo_light             String?   @default("/assets/logo_light.png") |   logo_light             String?   @default("/assets/logo_light.png") | ||||||
|   favicon                String?   @default("/assets/logo_square.svg") |   favicon                String?   @default("/assets/logo_square.svg") | ||||||
|  |   metrics_enabled        Boolean   @default(true) | ||||||
|  |   metrics_anonymous_id   String? | ||||||
|  |   metrics_last_sent      DateTime? | ||||||
|  |   color_theme            String    @default("default") | ||||||
| } | } | ||||||
|  |  | ||||||
| model update_history { | model update_history { | ||||||
|   | |||||||
| @@ -16,12 +16,28 @@ function getOptimizedDatabaseUrl() { | |||||||
| 	// Parse the URL | 	// Parse the URL | ||||||
| 	const url = new URL(originalUrl); | 	const url = new URL(originalUrl); | ||||||
|  |  | ||||||
| 	// Add connection pooling parameters for multiple instances | 	// Add connection pooling parameters - configurable via environment variables | ||||||
| 	url.searchParams.set("connection_limit", "5"); // Reduced from default 10 | 	const connectionLimit = process.env.DB_CONNECTION_LIMIT || "30"; | ||||||
| 	url.searchParams.set("pool_timeout", "10"); // 10 seconds | 	const poolTimeout = process.env.DB_POOL_TIMEOUT || "20"; | ||||||
| 	url.searchParams.set("connect_timeout", "10"); // 10 seconds | 	const connectTimeout = process.env.DB_CONNECT_TIMEOUT || "10"; | ||||||
| 	url.searchParams.set("idle_timeout", "300"); // 5 minutes | 	const idleTimeout = process.env.DB_IDLE_TIMEOUT || "300"; | ||||||
| 	url.searchParams.set("max_lifetime", "1800"); // 30 minutes | 	const maxLifetime = process.env.DB_MAX_LIFETIME || "1800"; | ||||||
|  |  | ||||||
|  | 	url.searchParams.set("connection_limit", connectionLimit); | ||||||
|  | 	url.searchParams.set("pool_timeout", poolTimeout); | ||||||
|  | 	url.searchParams.set("connect_timeout", connectTimeout); | ||||||
|  | 	url.searchParams.set("idle_timeout", idleTimeout); | ||||||
|  | 	url.searchParams.set("max_lifetime", maxLifetime); | ||||||
|  |  | ||||||
|  | 	// Log connection pool settings in development/debug mode | ||||||
|  | 	if ( | ||||||
|  | 		process.env.ENABLE_LOGGING === "true" || | ||||||
|  | 		process.env.LOG_LEVEL === "debug" | ||||||
|  | 	) { | ||||||
|  | 		console.log( | ||||||
|  | 			`[Database Pool] connection_limit=${connectionLimit}, pool_timeout=${poolTimeout}s, connect_timeout=${connectTimeout}s`, | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	return url.toString(); | 	return url.toString(); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -218,6 +218,30 @@ router.post( | |||||||
| 	}, | 	}, | ||||||
| ); | ); | ||||||
|  |  | ||||||
|  | // Trigger manual Docker inventory cleanup | ||||||
|  | router.post( | ||||||
|  | 	"/trigger/docker-inventory-cleanup", | ||||||
|  | 	authenticateToken, | ||||||
|  | 	async (_req, res) => { | ||||||
|  | 		try { | ||||||
|  | 			const job = await queueManager.triggerDockerInventoryCleanup(); | ||||||
|  | 			res.json({ | ||||||
|  | 				success: true, | ||||||
|  | 				data: { | ||||||
|  | 					jobId: job.id, | ||||||
|  | 					message: "Docker inventory cleanup triggered successfully", | ||||||
|  | 				}, | ||||||
|  | 			}); | ||||||
|  | 		} catch (error) { | ||||||
|  | 			console.error("Error triggering Docker inventory cleanup:", error); | ||||||
|  | 			res.status(500).json({ | ||||||
|  | 				success: false, | ||||||
|  | 				error: "Failed to trigger Docker inventory cleanup", | ||||||
|  | 			}); | ||||||
|  | 		} | ||||||
|  | 	}, | ||||||
|  | ); | ||||||
|  |  | ||||||
| // Get queue health status | // Get queue health status | ||||||
| router.get("/health", authenticateToken, async (_req, res) => { | router.get("/health", authenticateToken, async (_req, res) => { | ||||||
| 	try { | 	try { | ||||||
| @@ -274,6 +298,7 @@ router.get("/overview", authenticateToken, async (_req, res) => { | |||||||
| 			queueManager.getRecentJobs(QUEUE_NAMES.SESSION_CLEANUP, 1), | 			queueManager.getRecentJobs(QUEUE_NAMES.SESSION_CLEANUP, 1), | ||||||
| 			queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_REPO_CLEANUP, 1), | 			queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_REPO_CLEANUP, 1), | ||||||
| 			queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP, 1), | 			queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP, 1), | ||||||
|  | 			queueManager.getRecentJobs(QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP, 1), | ||||||
| 			queueManager.getRecentJobs(QUEUE_NAMES.AGENT_COMMANDS, 1), | 			queueManager.getRecentJobs(QUEUE_NAMES.AGENT_COMMANDS, 1), | ||||||
| 		]); | 		]); | ||||||
|  |  | ||||||
| @@ -283,19 +308,22 @@ router.get("/overview", authenticateToken, async (_req, res) => { | |||||||
| 				stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].delayed + | 				stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].delayed + | ||||||
| 				stats[QUEUE_NAMES.SESSION_CLEANUP].delayed + | 				stats[QUEUE_NAMES.SESSION_CLEANUP].delayed + | ||||||
| 				stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].delayed + | 				stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].delayed + | ||||||
| 				stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].delayed, | 				stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].delayed + | ||||||
|  | 				stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].delayed, | ||||||
|  |  | ||||||
| 			runningTasks: | 			runningTasks: | ||||||
| 				stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].active + | 				stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].active + | ||||||
| 				stats[QUEUE_NAMES.SESSION_CLEANUP].active + | 				stats[QUEUE_NAMES.SESSION_CLEANUP].active + | ||||||
| 				stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].active + | 				stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].active + | ||||||
| 				stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].active, | 				stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].active + | ||||||
|  | 				stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].active, | ||||||
|  |  | ||||||
| 			failedTasks: | 			failedTasks: | ||||||
| 				stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].failed + | 				stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].failed + | ||||||
| 				stats[QUEUE_NAMES.SESSION_CLEANUP].failed + | 				stats[QUEUE_NAMES.SESSION_CLEANUP].failed + | ||||||
| 				stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].failed + | 				stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].failed + | ||||||
| 				stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].failed, | 				stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].failed + | ||||||
|  | 				stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].failed, | ||||||
|  |  | ||||||
| 			totalAutomations: Object.values(stats).reduce((sum, queueStats) => { | 			totalAutomations: Object.values(stats).reduce((sum, queueStats) => { | ||||||
| 				return ( | 				return ( | ||||||
| @@ -375,10 +403,11 @@ router.get("/overview", authenticateToken, async (_req, res) => { | |||||||
| 					stats: stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP], | 					stats: stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP], | ||||||
| 				}, | 				}, | ||||||
| 				{ | 				{ | ||||||
| 					name: "Collect Host Statistics", | 					name: "Docker Inventory Cleanup", | ||||||
| 					queue: QUEUE_NAMES.AGENT_COMMANDS, | 					queue: QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP, | ||||||
| 					description: "Collects package statistics from connected agents only", | 					description: | ||||||
| 					schedule: `Every ${settings.update_interval} minutes (Agent-driven)`, | 						"Removes Docker containers and images for non-existent hosts", | ||||||
|  | 					schedule: "Daily at 4 AM", | ||||||
| 					lastRun: recentJobs[4][0]?.finishedOn | 					lastRun: recentJobs[4][0]?.finishedOn | ||||||
| 						? new Date(recentJobs[4][0].finishedOn).toLocaleString() | 						? new Date(recentJobs[4][0].finishedOn).toLocaleString() | ||||||
| 						: "Never", | 						: "Never", | ||||||
| @@ -388,6 +417,22 @@ router.get("/overview", authenticateToken, async (_req, res) => { | |||||||
| 						: recentJobs[4][0] | 						: recentJobs[4][0] | ||||||
| 							? "Success" | 							? "Success" | ||||||
| 							: "Never run", | 							: "Never run", | ||||||
|  | 					stats: stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP], | ||||||
|  | 				}, | ||||||
|  | 				{ | ||||||
|  | 					name: "Collect Host Statistics", | ||||||
|  | 					queue: QUEUE_NAMES.AGENT_COMMANDS, | ||||||
|  | 					description: "Collects package statistics from connected agents only", | ||||||
|  | 					schedule: `Every ${settings.update_interval} minutes (Agent-driven)`, | ||||||
|  | 					lastRun: recentJobs[5][0]?.finishedOn | ||||||
|  | 						? new Date(recentJobs[5][0].finishedOn).toLocaleString() | ||||||
|  | 						: "Never", | ||||||
|  | 					lastRunTimestamp: recentJobs[5][0]?.finishedOn || 0, | ||||||
|  | 					status: recentJobs[5][0]?.failedReason | ||||||
|  | 						? "Failed" | ||||||
|  | 						: recentJobs[5][0] | ||||||
|  | 							? "Success" | ||||||
|  | 							: "Never run", | ||||||
| 					stats: stats[QUEUE_NAMES.AGENT_COMMANDS], | 					stats: stats[QUEUE_NAMES.AGENT_COMMANDS], | ||||||
| 				}, | 				}, | ||||||
| 			].sort((a, b) => { | 			].sort((a, b) => { | ||||||
|   | |||||||
| @@ -193,11 +193,16 @@ router.get( | |||||||
| 	}, | 	}, | ||||||
| ); | ); | ||||||
|  |  | ||||||
| // Get hosts with their update status | // Get hosts with their update status - OPTIMIZED | ||||||
| router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => { | router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => { | ||||||
| 	try { | 	try { | ||||||
|  | 		// Get settings once (outside the loop) | ||||||
|  | 		const settings = await prisma.settings.findFirst(); | ||||||
|  | 		const updateIntervalMinutes = settings?.update_interval || 60; | ||||||
|  | 		const thresholdMinutes = updateIntervalMinutes * 2; | ||||||
|  |  | ||||||
|  | 		// Fetch hosts with groups | ||||||
| 		const hosts = await prisma.hosts.findMany({ | 		const hosts = await prisma.hosts.findMany({ | ||||||
| 			// Show all hosts regardless of status |  | ||||||
| 			select: { | 			select: { | ||||||
| 				id: true, | 				id: true, | ||||||
| 				machine_id: true, | 				machine_id: true, | ||||||
| @@ -223,40 +228,45 @@ router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => { | |||||||
| 						}, | 						}, | ||||||
| 					}, | 					}, | ||||||
| 				}, | 				}, | ||||||
| 				_count: { |  | ||||||
| 					select: { |  | ||||||
| 						host_packages: { |  | ||||||
| 							where: { |  | ||||||
| 								needs_update: true, |  | ||||||
| 							}, |  | ||||||
| 						}, |  | ||||||
| 					}, |  | ||||||
| 				}, |  | ||||||
| 			}, | 			}, | ||||||
| 			orderBy: { last_update: "desc" }, | 			orderBy: { last_update: "desc" }, | ||||||
| 		}); | 		}); | ||||||
|  |  | ||||||
| 		// Get update counts for each host separately | 		// OPTIMIZATION: Get all package counts in 2 batch queries instead of N*2 queries | ||||||
| 		const hostsWithUpdateInfo = await Promise.all( | 		const hostIds = hosts.map((h) => h.id); | ||||||
| 			hosts.map(async (host) => { |  | ||||||
| 				const updatesCount = await prisma.host_packages.count({ | 		const [updateCounts, totalCounts] = await Promise.all([ | ||||||
|  | 			// Get update counts for all hosts at once | ||||||
|  | 			prisma.host_packages.groupBy({ | ||||||
|  | 				by: ["host_id"], | ||||||
| 				where: { | 				where: { | ||||||
| 						host_id: host.id, | 					host_id: { in: hostIds }, | ||||||
| 					needs_update: true, | 					needs_update: true, | ||||||
| 				}, | 				}, | ||||||
| 				}); | 				_count: { id: true }, | ||||||
|  | 			}), | ||||||
| 				// Get total packages count for this host | 			// Get total counts for all hosts at once | ||||||
| 				const totalPackagesCount = await prisma.host_packages.count({ | 			prisma.host_packages.groupBy({ | ||||||
|  | 				by: ["host_id"], | ||||||
| 				where: { | 				where: { | ||||||
| 						host_id: host.id, | 					host_id: { in: hostIds }, | ||||||
| 				}, | 				}, | ||||||
| 				}); | 				_count: { id: true }, | ||||||
|  | 			}), | ||||||
|  | 		]); | ||||||
|  |  | ||||||
| 				// Get the agent update interval setting for stale calculation | 		// Create lookup maps for O(1) access | ||||||
| 				const settings = await prisma.settings.findFirst(); | 		const updateCountMap = new Map( | ||||||
| 				const updateIntervalMinutes = settings?.update_interval || 60; | 			updateCounts.map((item) => [item.host_id, item._count.id]), | ||||||
| 				const thresholdMinutes = updateIntervalMinutes * 2; | 		); | ||||||
|  | 		const totalCountMap = new Map( | ||||||
|  | 			totalCounts.map((item) => [item.host_id, item._count.id]), | ||||||
|  | 		); | ||||||
|  |  | ||||||
|  | 		// Process hosts with counts from maps (no more DB queries!) | ||||||
|  | 		const hostsWithUpdateInfo = hosts.map((host) => { | ||||||
|  | 			const updatesCount = updateCountMap.get(host.id) || 0; | ||||||
|  | 			const totalPackagesCount = totalCountMap.get(host.id) || 0; | ||||||
|  |  | ||||||
| 			// Calculate effective status based on reporting interval | 			// Calculate effective status based on reporting interval | ||||||
| 			const isStale = moment(host.last_update).isBefore( | 			const isStale = moment(host.last_update).isBefore( | ||||||
| @@ -276,8 +286,7 @@ router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => { | |||||||
| 				isStale, | 				isStale, | ||||||
| 				effectiveStatus, | 				effectiveStatus, | ||||||
| 			}; | 			}; | ||||||
| 			}), | 		}); | ||||||
| 		); |  | ||||||
|  |  | ||||||
| 		res.json(hostsWithUpdateInfo); | 		res.json(hostsWithUpdateInfo); | ||||||
| 	} catch (error) { | 	} catch (error) { | ||||||
|   | |||||||
| @@ -522,7 +522,8 @@ router.get("/updates", authenticateToken, async (req, res) => { | |||||||
| 	} | 	} | ||||||
| }); | }); | ||||||
|  |  | ||||||
| // POST /api/v1/docker/collect - Collect Docker data from agent | // POST /api/v1/docker/collect - Collect Docker data from agent (DEPRECATED - kept for backward compatibility) | ||||||
|  | // New agents should use POST /api/v1/integrations/docker | ||||||
| router.post("/collect", async (req, res) => { | router.post("/collect", async (req, res) => { | ||||||
| 	try { | 	try { | ||||||
| 		const { apiId, apiKey, containers, images, updates } = req.body; | 		const { apiId, apiKey, containers, images, updates } = req.body; | ||||||
| @@ -745,6 +746,322 @@ router.post("/collect", async (req, res) => { | |||||||
| 	} | 	} | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | // POST /api/v1/integrations/docker - New integration endpoint for Docker data collection | ||||||
|  | router.post("/../integrations/docker", async (req, res) => { | ||||||
|  | 	try { | ||||||
|  | 		const apiId = req.headers["x-api-id"]; | ||||||
|  | 		const apiKey = req.headers["x-api-key"]; | ||||||
|  | 		const { | ||||||
|  | 			containers, | ||||||
|  | 			images, | ||||||
|  | 			updates, | ||||||
|  | 			daemon_info: _daemon_info, | ||||||
|  | 			hostname, | ||||||
|  | 			machine_id, | ||||||
|  | 			agent_version: _agent_version, | ||||||
|  | 		} = req.body; | ||||||
|  |  | ||||||
|  | 		console.log( | ||||||
|  | 			`[Docker Integration] Received data from ${hostname || machine_id}`, | ||||||
|  | 		); | ||||||
|  |  | ||||||
|  | 		// Validate API credentials | ||||||
|  | 		const host = await prisma.hosts.findFirst({ | ||||||
|  | 			where: { api_id: apiId, api_key: apiKey }, | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		if (!host) { | ||||||
|  | 			console.warn("[Docker Integration] Invalid API credentials"); | ||||||
|  | 			return res.status(401).json({ error: "Invalid API credentials" }); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		console.log( | ||||||
|  | 			`[Docker Integration] Processing for host: ${host.friendly_name}`, | ||||||
|  | 		); | ||||||
|  |  | ||||||
|  | 		const now = new Date(); | ||||||
|  |  | ||||||
|  | 		// Helper function to validate and parse dates | ||||||
|  | 		const parseDate = (dateString) => { | ||||||
|  | 			if (!dateString) return now; | ||||||
|  | 			const date = new Date(dateString); | ||||||
|  | 			return Number.isNaN(date.getTime()) ? now : date; | ||||||
|  | 		}; | ||||||
|  |  | ||||||
|  | 		let containersProcessed = 0; | ||||||
|  | 		let imagesProcessed = 0; | ||||||
|  | 		let updatesProcessed = 0; | ||||||
|  |  | ||||||
|  | 		// Process containers | ||||||
|  | 		if (containers && Array.isArray(containers)) { | ||||||
|  | 			console.log( | ||||||
|  | 				`[Docker Integration] Processing ${containers.length} containers`, | ||||||
|  | 			); | ||||||
|  | 			for (const containerData of containers) { | ||||||
|  | 				const containerId = uuidv4(); | ||||||
|  |  | ||||||
|  | 				// Find or create image | ||||||
|  | 				let imageId = null; | ||||||
|  | 				if (containerData.image_repository && containerData.image_tag) { | ||||||
|  | 					const image = await prisma.docker_images.upsert({ | ||||||
|  | 						where: { | ||||||
|  | 							repository_tag_image_id: { | ||||||
|  | 								repository: containerData.image_repository, | ||||||
|  | 								tag: containerData.image_tag, | ||||||
|  | 								image_id: containerData.image_id || "unknown", | ||||||
|  | 							}, | ||||||
|  | 						}, | ||||||
|  | 						update: { | ||||||
|  | 							last_checked: now, | ||||||
|  | 							updated_at: now, | ||||||
|  | 						}, | ||||||
|  | 						create: { | ||||||
|  | 							id: uuidv4(), | ||||||
|  | 							repository: containerData.image_repository, | ||||||
|  | 							tag: containerData.image_tag, | ||||||
|  | 							image_id: containerData.image_id || "unknown", | ||||||
|  | 							source: containerData.image_source || "docker-hub", | ||||||
|  | 							created_at: parseDate(containerData.created_at), | ||||||
|  | 							updated_at: now, | ||||||
|  | 						}, | ||||||
|  | 					}); | ||||||
|  | 					imageId = image.id; | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				// Upsert container | ||||||
|  | 				await prisma.docker_containers.upsert({ | ||||||
|  | 					where: { | ||||||
|  | 						host_id_container_id: { | ||||||
|  | 							host_id: host.id, | ||||||
|  | 							container_id: containerData.container_id, | ||||||
|  | 						}, | ||||||
|  | 					}, | ||||||
|  | 					update: { | ||||||
|  | 						name: containerData.name, | ||||||
|  | 						image_id: imageId, | ||||||
|  | 						image_name: containerData.image_name, | ||||||
|  | 						image_tag: containerData.image_tag || "latest", | ||||||
|  | 						status: containerData.status, | ||||||
|  | 						state: containerData.state || containerData.status, | ||||||
|  | 						ports: containerData.ports || null, | ||||||
|  | 						started_at: containerData.started_at | ||||||
|  | 							? parseDate(containerData.started_at) | ||||||
|  | 							: null, | ||||||
|  | 						updated_at: now, | ||||||
|  | 						last_checked: now, | ||||||
|  | 					}, | ||||||
|  | 					create: { | ||||||
|  | 						id: containerId, | ||||||
|  | 						host_id: host.id, | ||||||
|  | 						container_id: containerData.container_id, | ||||||
|  | 						name: containerData.name, | ||||||
|  | 						image_id: imageId, | ||||||
|  | 						image_name: containerData.image_name, | ||||||
|  | 						image_tag: containerData.image_tag || "latest", | ||||||
|  | 						status: containerData.status, | ||||||
|  | 						state: containerData.state || containerData.status, | ||||||
|  | 						ports: containerData.ports || null, | ||||||
|  | 						created_at: parseDate(containerData.created_at), | ||||||
|  | 						started_at: containerData.started_at | ||||||
|  | 							? parseDate(containerData.started_at) | ||||||
|  | 							: null, | ||||||
|  | 						updated_at: now, | ||||||
|  | 					}, | ||||||
|  | 				}); | ||||||
|  | 				containersProcessed++; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Process standalone images | ||||||
|  | 		if (images && Array.isArray(images)) { | ||||||
|  | 			console.log(`[Docker Integration] Processing ${images.length} images`); | ||||||
|  | 			for (const imageData of images) { | ||||||
|  | 				await prisma.docker_images.upsert({ | ||||||
|  | 					where: { | ||||||
|  | 						repository_tag_image_id: { | ||||||
|  | 							repository: imageData.repository, | ||||||
|  | 							tag: imageData.tag, | ||||||
|  | 							image_id: imageData.image_id, | ||||||
|  | 						}, | ||||||
|  | 					}, | ||||||
|  | 					update: { | ||||||
|  | 						size_bytes: imageData.size_bytes | ||||||
|  | 							? BigInt(imageData.size_bytes) | ||||||
|  | 							: null, | ||||||
|  | 						digest: imageData.digest || null, | ||||||
|  | 						last_checked: now, | ||||||
|  | 						updated_at: now, | ||||||
|  | 					}, | ||||||
|  | 					create: { | ||||||
|  | 						id: uuidv4(), | ||||||
|  | 						repository: imageData.repository, | ||||||
|  | 						tag: imageData.tag, | ||||||
|  | 						image_id: imageData.image_id, | ||||||
|  | 						digest: imageData.digest, | ||||||
|  | 						size_bytes: imageData.size_bytes | ||||||
|  | 							? BigInt(imageData.size_bytes) | ||||||
|  | 							: null, | ||||||
|  | 						source: imageData.source || "docker-hub", | ||||||
|  | 						created_at: parseDate(imageData.created_at), | ||||||
|  | 						updated_at: now, | ||||||
|  | 					}, | ||||||
|  | 				}); | ||||||
|  | 				imagesProcessed++; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Process updates | ||||||
|  | 		if (updates && Array.isArray(updates)) { | ||||||
|  | 			console.log(`[Docker Integration] Processing ${updates.length} updates`); | ||||||
|  | 			for (const updateData of updates) { | ||||||
|  | 				// Find the image by repository and image_id | ||||||
|  | 				const image = await prisma.docker_images.findFirst({ | ||||||
|  | 					where: { | ||||||
|  | 						repository: updateData.repository, | ||||||
|  | 						tag: updateData.current_tag, | ||||||
|  | 						image_id: updateData.image_id, | ||||||
|  | 					}, | ||||||
|  | 				}); | ||||||
|  |  | ||||||
|  | 				if (image) { | ||||||
|  | 					// Store digest info in changelog_url field as JSON | ||||||
|  | 					const digestInfo = JSON.stringify({ | ||||||
|  | 						method: "digest_comparison", | ||||||
|  | 						current_digest: updateData.current_digest, | ||||||
|  | 						available_digest: updateData.available_digest, | ||||||
|  | 					}); | ||||||
|  |  | ||||||
|  | 					// Upsert the update record | ||||||
|  | 					await prisma.docker_image_updates.upsert({ | ||||||
|  | 						where: { | ||||||
|  | 							image_id_available_tag: { | ||||||
|  | 								image_id: image.id, | ||||||
|  | 								available_tag: updateData.available_tag, | ||||||
|  | 							}, | ||||||
|  | 						}, | ||||||
|  | 						update: { | ||||||
|  | 							updated_at: now, | ||||||
|  | 							changelog_url: digestInfo, | ||||||
|  | 							severity: "digest_changed", | ||||||
|  | 						}, | ||||||
|  | 						create: { | ||||||
|  | 							id: uuidv4(), | ||||||
|  | 							image_id: image.id, | ||||||
|  | 							current_tag: updateData.current_tag, | ||||||
|  | 							available_tag: updateData.available_tag, | ||||||
|  | 							severity: "digest_changed", | ||||||
|  | 							changelog_url: digestInfo, | ||||||
|  | 							updated_at: now, | ||||||
|  | 						}, | ||||||
|  | 					}); | ||||||
|  | 					updatesProcessed++; | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		console.log( | ||||||
|  | 			`[Docker Integration] Successfully processed: ${containersProcessed} containers, ${imagesProcessed} images, ${updatesProcessed} updates`, | ||||||
|  | 		); | ||||||
|  |  | ||||||
|  | 		res.json({ | ||||||
|  | 			message: "Docker data collected successfully", | ||||||
|  | 			containers_received: containersProcessed, | ||||||
|  | 			images_received: imagesProcessed, | ||||||
|  | 			updates_found: updatesProcessed, | ||||||
|  | 		}); | ||||||
|  | 	} catch (error) { | ||||||
|  | 		console.error("[Docker Integration] Error collecting Docker data:", error); | ||||||
|  | 		console.error("[Docker Integration] Error stack:", error.stack); | ||||||
|  | 		res.status(500).json({ | ||||||
|  | 			error: "Failed to collect Docker data", | ||||||
|  | 			message: error.message, | ||||||
|  | 			details: process.env.NODE_ENV === "development" ? error.stack : undefined, | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // DELETE /api/v1/docker/containers/:id - Delete a container | ||||||
|  | router.delete("/containers/:id", authenticateToken, async (req, res) => { | ||||||
|  | 	try { | ||||||
|  | 		const { id } = req.params; | ||||||
|  |  | ||||||
|  | 		// Check if container exists | ||||||
|  | 		const container = await prisma.docker_containers.findUnique({ | ||||||
|  | 			where: { id }, | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		if (!container) { | ||||||
|  | 			return res.status(404).json({ error: "Container not found" }); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Delete the container | ||||||
|  | 		await prisma.docker_containers.delete({ | ||||||
|  | 			where: { id }, | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		console.log(`🗑️  Deleted container: ${container.name} (${id})`); | ||||||
|  |  | ||||||
|  | 		res.json({ | ||||||
|  | 			success: true, | ||||||
|  | 			message: `Container ${container.name} deleted successfully`, | ||||||
|  | 		}); | ||||||
|  | 	} catch (error) { | ||||||
|  | 		console.error("Error deleting container:", error); | ||||||
|  | 		res.status(500).json({ error: "Failed to delete container" }); | ||||||
|  | 	} | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // DELETE /api/v1/docker/images/:id - Delete an image | ||||||
|  | router.delete("/images/:id", authenticateToken, async (req, res) => { | ||||||
|  | 	try { | ||||||
|  | 		const { id } = req.params; | ||||||
|  |  | ||||||
|  | 		// Check if image exists | ||||||
|  | 		const image = await prisma.docker_images.findUnique({ | ||||||
|  | 			where: { id }, | ||||||
|  | 			include: { | ||||||
|  | 				_count: { | ||||||
|  | 					select: { | ||||||
|  | 						docker_containers: true, | ||||||
|  | 					}, | ||||||
|  | 				}, | ||||||
|  | 			}, | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		if (!image) { | ||||||
|  | 			return res.status(404).json({ error: "Image not found" }); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Check if image is in use by containers | ||||||
|  | 		if (image._count.docker_containers > 0) { | ||||||
|  | 			return res.status(400).json({ | ||||||
|  | 				error: `Cannot delete image: ${image._count.docker_containers} container(s) are using this image`, | ||||||
|  | 				containersCount: image._count.docker_containers, | ||||||
|  | 			}); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Delete image updates first | ||||||
|  | 		await prisma.docker_image_updates.deleteMany({ | ||||||
|  | 			where: { image_id: id }, | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		// Delete the image | ||||||
|  | 		await prisma.docker_images.delete({ | ||||||
|  | 			where: { id }, | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		console.log(`🗑️  Deleted image: ${image.repository}:${image.tag} (${id})`); | ||||||
|  |  | ||||||
|  | 		res.json({ | ||||||
|  | 			success: true, | ||||||
|  | 			message: `Image ${image.repository}:${image.tag} deleted successfully`, | ||||||
|  | 		}); | ||||||
|  | 	} catch (error) { | ||||||
|  | 		console.error("Error deleting image:", error); | ||||||
|  | 		res.status(500).json({ error: "Failed to delete image" }); | ||||||
|  | 	} | ||||||
|  | }); | ||||||
|  |  | ||||||
| // GET /api/v1/docker/agent - Serve the Docker agent installation script | // GET /api/v1/docker/agent - Serve the Docker agent installation script | ||||||
| router.get("/agent", async (_req, res) => { | router.get("/agent", async (_req, res) => { | ||||||
| 	try { | 	try { | ||||||
|   | |||||||
| @@ -356,6 +356,26 @@ router.post( | |||||||
| 			}); | 			}); | ||||||
| 		} catch (error) { | 		} catch (error) { | ||||||
| 			console.error("Host creation error:", error); | 			console.error("Host creation error:", error); | ||||||
|  |  | ||||||
|  | 			// Check if error is related to connection pool exhaustion | ||||||
|  | 			if ( | ||||||
|  | 				error.message && | ||||||
|  | 				(error.message.includes("connection pool") || | ||||||
|  | 					error.message.includes("Timed out fetching") || | ||||||
|  | 					error.message.includes("pool timeout")) | ||||||
|  | 			) { | ||||||
|  | 				console.error("⚠️  DATABASE CONNECTION POOL EXHAUSTED!"); | ||||||
|  | 				console.error( | ||||||
|  | 					`⚠️  Current limit: DB_CONNECTION_LIMIT=${process.env.DB_CONNECTION_LIMIT || "30"}`, | ||||||
|  | 				); | ||||||
|  | 				console.error( | ||||||
|  | 					`⚠️  Pool timeout: DB_POOL_TIMEOUT=${process.env.DB_POOL_TIMEOUT || "20"}s`, | ||||||
|  | 				); | ||||||
|  | 				console.error( | ||||||
|  | 					"⚠️  Suggestion: Increase DB_CONNECTION_LIMIT in your .env file", | ||||||
|  | 				); | ||||||
|  | 			} | ||||||
|  |  | ||||||
| 			res.status(500).json({ error: "Failed to create host" }); | 			res.status(500).json({ error: "Failed to create host" }); | ||||||
| 		} | 		} | ||||||
| 	}, | 	}, | ||||||
| @@ -786,19 +806,41 @@ router.get("/info", validateApiCredentials, async (req, res) => { | |||||||
| // Ping endpoint for health checks (now uses API credentials) | // Ping endpoint for health checks (now uses API credentials) | ||||||
| router.post("/ping", validateApiCredentials, async (req, res) => { | router.post("/ping", validateApiCredentials, async (req, res) => { | ||||||
| 	try { | 	try { | ||||||
| 		// Update last update timestamp | 		const now = new Date(); | ||||||
|  | 		const lastUpdate = req.hostRecord.last_update; | ||||||
|  |  | ||||||
|  | 		// Detect if this is an agent startup (first ping or after long absence) | ||||||
|  | 		const timeSinceLastUpdate = lastUpdate ? now - lastUpdate : null; | ||||||
|  | 		const isStartup = | ||||||
|  | 			!timeSinceLastUpdate || timeSinceLastUpdate > 5 * 60 * 1000; // 5 minutes | ||||||
|  |  | ||||||
|  | 		// Log agent startup | ||||||
|  | 		if (isStartup) { | ||||||
|  | 			console.log( | ||||||
|  | 				`🚀 Agent startup detected: ${req.hostRecord.friendly_name} (${req.hostRecord.hostname || req.hostRecord.api_id})`, | ||||||
|  | 			); | ||||||
|  |  | ||||||
|  | 			// Check if status was previously offline | ||||||
|  | 			if (req.hostRecord.status === "offline") { | ||||||
|  | 				console.log(`✅ Agent back online: ${req.hostRecord.friendly_name}`); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Update last update timestamp and set status to active | ||||||
| 		await prisma.hosts.update({ | 		await prisma.hosts.update({ | ||||||
| 			where: { id: req.hostRecord.id }, | 			where: { id: req.hostRecord.id }, | ||||||
| 			data: { | 			data: { | ||||||
| 				last_update: new Date(), | 				last_update: now, | ||||||
| 				updated_at: new Date(), | 				updated_at: now, | ||||||
|  | 				status: "active", | ||||||
| 			}, | 			}, | ||||||
| 		}); | 		}); | ||||||
|  |  | ||||||
| 		const response = { | 		const response = { | ||||||
| 			message: "Ping successful", | 			message: "Ping successful", | ||||||
| 			timestamp: new Date().toISOString(), | 			timestamp: now.toISOString(), | ||||||
| 			friendlyName: req.hostRecord.friendly_name, | 			friendlyName: req.hostRecord.friendly_name, | ||||||
|  | 			agentStartup: isStartup, | ||||||
| 		}; | 		}; | ||||||
|  |  | ||||||
| 		// Check if this is a crontab update trigger | 		// Check if this is a crontab update trigger | ||||||
| @@ -1388,6 +1430,69 @@ router.patch( | |||||||
| 	}, | 	}, | ||||||
| ); | ); | ||||||
|  |  | ||||||
|  | // Force agent update for specific host | ||||||
|  | router.post( | ||||||
|  | 	"/:hostId/force-agent-update", | ||||||
|  | 	authenticateToken, | ||||||
|  | 	requireManageHosts, | ||||||
|  | 	async (req, res) => { | ||||||
|  | 		try { | ||||||
|  | 			const { hostId } = req.params; | ||||||
|  |  | ||||||
|  | 			// Get host to verify it exists | ||||||
|  | 			const host = await prisma.hosts.findUnique({ | ||||||
|  | 				where: { id: hostId }, | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			if (!host) { | ||||||
|  | 				return res.status(404).json({ error: "Host not found" }); | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			// Get queue manager | ||||||
|  | 			const { QUEUE_NAMES } = require("../services/automation"); | ||||||
|  | 			const queueManager = req.app.locals.queueManager; | ||||||
|  |  | ||||||
|  | 			if (!queueManager) { | ||||||
|  | 				return res.status(500).json({ | ||||||
|  | 					error: "Queue manager not available", | ||||||
|  | 				}); | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			// Get the agent-commands queue | ||||||
|  | 			const queue = queueManager.queues[QUEUE_NAMES.AGENT_COMMANDS]; | ||||||
|  |  | ||||||
|  | 			// Add job to queue | ||||||
|  | 			await queue.add( | ||||||
|  | 				"update_agent", | ||||||
|  | 				{ | ||||||
|  | 					api_id: host.api_id, | ||||||
|  | 					type: "update_agent", | ||||||
|  | 				}, | ||||||
|  | 				{ | ||||||
|  | 					attempts: 3, | ||||||
|  | 					backoff: { | ||||||
|  | 						type: "exponential", | ||||||
|  | 						delay: 2000, | ||||||
|  | 					}, | ||||||
|  | 				}, | ||||||
|  | 			); | ||||||
|  |  | ||||||
|  | 			res.json({ | ||||||
|  | 				success: true, | ||||||
|  | 				message: "Agent update queued successfully", | ||||||
|  | 				host: { | ||||||
|  | 					id: host.id, | ||||||
|  | 					friendlyName: host.friendly_name, | ||||||
|  | 					apiId: host.api_id, | ||||||
|  | 				}, | ||||||
|  | 			}); | ||||||
|  | 		} catch (error) { | ||||||
|  | 			console.error("Force agent update error:", error); | ||||||
|  | 			res.status(500).json({ error: "Failed to force agent update" }); | ||||||
|  | 		} | ||||||
|  | 	}, | ||||||
|  | ); | ||||||
|  |  | ||||||
| // Serve the installation script (requires API authentication) | // Serve the installation script (requires API authentication) | ||||||
| router.get("/install", async (req, res) => { | router.get("/install", async (req, res) => { | ||||||
| 	try { | 	try { | ||||||
| @@ -1441,10 +1546,12 @@ router.get("/install", async (req, res) => { | |||||||
|  |  | ||||||
| 		// Determine curl flags dynamically from settings (ignore self-signed) | 		// Determine curl flags dynamically from settings (ignore self-signed) | ||||||
| 		let curlFlags = "-s"; | 		let curlFlags = "-s"; | ||||||
|  | 		let skipSSLVerify = "false"; | ||||||
| 		try { | 		try { | ||||||
| 			const settings = await prisma.settings.findFirst(); | 			const settings = await prisma.settings.findFirst(); | ||||||
| 			if (settings && settings.ignore_ssl_self_signed === true) { | 			if (settings && settings.ignore_ssl_self_signed === true) { | ||||||
| 				curlFlags = "-sk"; | 				curlFlags = "-sk"; | ||||||
|  | 				skipSSLVerify = "true"; | ||||||
| 			} | 			} | ||||||
| 		} catch (_) {} | 		} catch (_) {} | ||||||
|  |  | ||||||
| @@ -1454,12 +1561,13 @@ router.get("/install", async (req, res) => { | |||||||
| 		// Get architecture parameter (default to amd64) | 		// Get architecture parameter (default to amd64) | ||||||
| 		const architecture = req.query.arch || "amd64"; | 		const architecture = req.query.arch || "amd64"; | ||||||
|  |  | ||||||
| 		// Inject the API credentials, server URL, curl flags, force flag, and architecture into the script | 		// Inject the API credentials, server URL, curl flags, SSL verify flag, force flag, and architecture into the script | ||||||
| 		const envVars = `#!/bin/bash | 		const envVars = `#!/bin/bash | ||||||
| export PATCHMON_URL="${serverUrl}" | export PATCHMON_URL="${serverUrl}" | ||||||
| export API_ID="${host.api_id}" | export API_ID="${host.api_id}" | ||||||
| export API_KEY="${host.api_key}" | export API_KEY="${host.api_key}" | ||||||
| export CURL_FLAGS="${curlFlags}" | export CURL_FLAGS="${curlFlags}" | ||||||
|  | export SKIP_SSL_VERIFY="${skipSSLVerify}" | ||||||
| export FORCE_INSTALL="${forceInstall ? "true" : "false"}" | export FORCE_INSTALL="${forceInstall ? "true" : "false"}" | ||||||
| export ARCHITECTURE="${architecture}" | export ARCHITECTURE="${architecture}" | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										242
									
								
								backend/src/routes/integrationRoutes.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										242
									
								
								backend/src/routes/integrationRoutes.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,242 @@ | |||||||
|  | const express = require("express"); | ||||||
|  | const { getPrismaClient } = require("../config/prisma"); | ||||||
|  | const { v4: uuidv4 } = require("uuid"); | ||||||
|  |  | ||||||
|  | const prisma = getPrismaClient(); | ||||||
|  | const router = express.Router(); | ||||||
|  |  | ||||||
|  | // POST /api/v1/integrations/docker - Docker data collection endpoint | ||||||
|  | router.post("/docker", async (req, res) => { | ||||||
|  | 	try { | ||||||
|  | 		const apiId = req.headers["x-api-id"]; | ||||||
|  | 		const apiKey = req.headers["x-api-key"]; | ||||||
|  | 		const { | ||||||
|  | 			containers, | ||||||
|  | 			images, | ||||||
|  | 			updates, | ||||||
|  | 			daemon_info: _daemon_info, | ||||||
|  | 			hostname, | ||||||
|  | 			machine_id, | ||||||
|  | 			agent_version: _agent_version, | ||||||
|  | 		} = req.body; | ||||||
|  |  | ||||||
|  | 		console.log( | ||||||
|  | 			`[Docker Integration] Received data from ${hostname || machine_id}`, | ||||||
|  | 		); | ||||||
|  |  | ||||||
|  | 		// Validate API credentials | ||||||
|  | 		const host = await prisma.hosts.findFirst({ | ||||||
|  | 			where: { api_id: apiId, api_key: apiKey }, | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		if (!host) { | ||||||
|  | 			console.warn("[Docker Integration] Invalid API credentials"); | ||||||
|  | 			return res.status(401).json({ error: "Invalid API credentials" }); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		console.log( | ||||||
|  | 			`[Docker Integration] Processing for host: ${host.friendly_name}`, | ||||||
|  | 		); | ||||||
|  |  | ||||||
|  | 		const now = new Date(); | ||||||
|  |  | ||||||
|  | 		// Helper function to validate and parse dates | ||||||
|  | 		const parseDate = (dateString) => { | ||||||
|  | 			if (!dateString) return now; | ||||||
|  | 			const date = new Date(dateString); | ||||||
|  | 			return Number.isNaN(date.getTime()) ? now : date; | ||||||
|  | 		}; | ||||||
|  |  | ||||||
|  | 		let containersProcessed = 0; | ||||||
|  | 		let imagesProcessed = 0; | ||||||
|  | 		let updatesProcessed = 0; | ||||||
|  |  | ||||||
|  | 		// Process containers | ||||||
|  | 		if (containers && Array.isArray(containers)) { | ||||||
|  | 			console.log( | ||||||
|  | 				`[Docker Integration] Processing ${containers.length} containers`, | ||||||
|  | 			); | ||||||
|  | 			for (const containerData of containers) { | ||||||
|  | 				const containerId = uuidv4(); | ||||||
|  |  | ||||||
|  | 				// Find or create image | ||||||
|  | 				let imageId = null; | ||||||
|  | 				if (containerData.image_repository && containerData.image_tag) { | ||||||
|  | 					const image = await prisma.docker_images.upsert({ | ||||||
|  | 						where: { | ||||||
|  | 							repository_tag_image_id: { | ||||||
|  | 								repository: containerData.image_repository, | ||||||
|  | 								tag: containerData.image_tag, | ||||||
|  | 								image_id: containerData.image_id || "unknown", | ||||||
|  | 							}, | ||||||
|  | 						}, | ||||||
|  | 						update: { | ||||||
|  | 							last_checked: now, | ||||||
|  | 							updated_at: now, | ||||||
|  | 						}, | ||||||
|  | 						create: { | ||||||
|  | 							id: uuidv4(), | ||||||
|  | 							repository: containerData.image_repository, | ||||||
|  | 							tag: containerData.image_tag, | ||||||
|  | 							image_id: containerData.image_id || "unknown", | ||||||
|  | 							source: containerData.image_source || "docker-hub", | ||||||
|  | 							created_at: parseDate(containerData.created_at), | ||||||
|  | 							updated_at: now, | ||||||
|  | 						}, | ||||||
|  | 					}); | ||||||
|  | 					imageId = image.id; | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				// Upsert container | ||||||
|  | 				await prisma.docker_containers.upsert({ | ||||||
|  | 					where: { | ||||||
|  | 						host_id_container_id: { | ||||||
|  | 							host_id: host.id, | ||||||
|  | 							container_id: containerData.container_id, | ||||||
|  | 						}, | ||||||
|  | 					}, | ||||||
|  | 					update: { | ||||||
|  | 						name: containerData.name, | ||||||
|  | 						image_id: imageId, | ||||||
|  | 						image_name: containerData.image_name, | ||||||
|  | 						image_tag: containerData.image_tag || "latest", | ||||||
|  | 						status: containerData.status, | ||||||
|  | 						state: containerData.state || containerData.status, | ||||||
|  | 						ports: containerData.ports || null, | ||||||
|  | 						started_at: containerData.started_at | ||||||
|  | 							? parseDate(containerData.started_at) | ||||||
|  | 							: null, | ||||||
|  | 						updated_at: now, | ||||||
|  | 						last_checked: now, | ||||||
|  | 					}, | ||||||
|  | 					create: { | ||||||
|  | 						id: containerId, | ||||||
|  | 						host_id: host.id, | ||||||
|  | 						container_id: containerData.container_id, | ||||||
|  | 						name: containerData.name, | ||||||
|  | 						image_id: imageId, | ||||||
|  | 						image_name: containerData.image_name, | ||||||
|  | 						image_tag: containerData.image_tag || "latest", | ||||||
|  | 						status: containerData.status, | ||||||
|  | 						state: containerData.state || containerData.status, | ||||||
|  | 						ports: containerData.ports || null, | ||||||
|  | 						created_at: parseDate(containerData.created_at), | ||||||
|  | 						started_at: containerData.started_at | ||||||
|  | 							? parseDate(containerData.started_at) | ||||||
|  | 							: null, | ||||||
|  | 						updated_at: now, | ||||||
|  | 					}, | ||||||
|  | 				}); | ||||||
|  | 				containersProcessed++; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Process standalone images | ||||||
|  | 		if (images && Array.isArray(images)) { | ||||||
|  | 			console.log(`[Docker Integration] Processing ${images.length} images`); | ||||||
|  | 			for (const imageData of images) { | ||||||
|  | 				await prisma.docker_images.upsert({ | ||||||
|  | 					where: { | ||||||
|  | 						repository_tag_image_id: { | ||||||
|  | 							repository: imageData.repository, | ||||||
|  | 							tag: imageData.tag, | ||||||
|  | 							image_id: imageData.image_id, | ||||||
|  | 						}, | ||||||
|  | 					}, | ||||||
|  | 					update: { | ||||||
|  | 						size_bytes: imageData.size_bytes | ||||||
|  | 							? BigInt(imageData.size_bytes) | ||||||
|  | 							: null, | ||||||
|  | 						digest: imageData.digest || null, | ||||||
|  | 						last_checked: now, | ||||||
|  | 						updated_at: now, | ||||||
|  | 					}, | ||||||
|  | 					create: { | ||||||
|  | 						id: uuidv4(), | ||||||
|  | 						repository: imageData.repository, | ||||||
|  | 						tag: imageData.tag, | ||||||
|  | 						image_id: imageData.image_id, | ||||||
|  | 						digest: imageData.digest, | ||||||
|  | 						size_bytes: imageData.size_bytes | ||||||
|  | 							? BigInt(imageData.size_bytes) | ||||||
|  | 							: null, | ||||||
|  | 						source: imageData.source || "docker-hub", | ||||||
|  | 						created_at: parseDate(imageData.created_at), | ||||||
|  | 						updated_at: now, | ||||||
|  | 					}, | ||||||
|  | 				}); | ||||||
|  | 				imagesProcessed++; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Process updates | ||||||
|  | 		if (updates && Array.isArray(updates)) { | ||||||
|  | 			console.log(`[Docker Integration] Processing ${updates.length} updates`); | ||||||
|  | 			for (const updateData of updates) { | ||||||
|  | 				// Find the image by repository and image_id | ||||||
|  | 				const image = await prisma.docker_images.findFirst({ | ||||||
|  | 					where: { | ||||||
|  | 						repository: updateData.repository, | ||||||
|  | 						tag: updateData.current_tag, | ||||||
|  | 						image_id: updateData.image_id, | ||||||
|  | 					}, | ||||||
|  | 				}); | ||||||
|  |  | ||||||
|  | 				if (image) { | ||||||
|  | 					// Store digest info in changelog_url field as JSON | ||||||
|  | 					const digestInfo = JSON.stringify({ | ||||||
|  | 						method: "digest_comparison", | ||||||
|  | 						current_digest: updateData.current_digest, | ||||||
|  | 						available_digest: updateData.available_digest, | ||||||
|  | 					}); | ||||||
|  |  | ||||||
|  | 					// Upsert the update record | ||||||
|  | 					await prisma.docker_image_updates.upsert({ | ||||||
|  | 						where: { | ||||||
|  | 							image_id_available_tag: { | ||||||
|  | 								image_id: image.id, | ||||||
|  | 								available_tag: updateData.available_tag, | ||||||
|  | 							}, | ||||||
|  | 						}, | ||||||
|  | 						update: { | ||||||
|  | 							updated_at: now, | ||||||
|  | 							changelog_url: digestInfo, | ||||||
|  | 							severity: "digest_changed", | ||||||
|  | 						}, | ||||||
|  | 						create: { | ||||||
|  | 							id: uuidv4(), | ||||||
|  | 							image_id: image.id, | ||||||
|  | 							current_tag: updateData.current_tag, | ||||||
|  | 							available_tag: updateData.available_tag, | ||||||
|  | 							severity: "digest_changed", | ||||||
|  | 							changelog_url: digestInfo, | ||||||
|  | 							updated_at: now, | ||||||
|  | 						}, | ||||||
|  | 					}); | ||||||
|  | 					updatesProcessed++; | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		console.log( | ||||||
|  | 			`[Docker Integration] Successfully processed: ${containersProcessed} containers, ${imagesProcessed} images, ${updatesProcessed} updates`, | ||||||
|  | 		); | ||||||
|  |  | ||||||
|  | 		res.json({ | ||||||
|  | 			message: "Docker data collected successfully", | ||||||
|  | 			containers_received: containersProcessed, | ||||||
|  | 			images_received: imagesProcessed, | ||||||
|  | 			updates_found: updatesProcessed, | ||||||
|  | 		}); | ||||||
|  | 	} catch (error) { | ||||||
|  | 		console.error("[Docker Integration] Error collecting Docker data:", error); | ||||||
|  | 		console.error("[Docker Integration] Error stack:", error.stack); | ||||||
|  | 		res.status(500).json({ | ||||||
|  | 			error: "Failed to collect Docker data", | ||||||
|  | 			message: error.message, | ||||||
|  | 			details: process.env.NODE_ENV === "development" ? error.stack : undefined, | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | module.exports = router; | ||||||
							
								
								
									
										148
									
								
								backend/src/routes/metricsRoutes.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										148
									
								
								backend/src/routes/metricsRoutes.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,148 @@ | |||||||
|  | const express = require("express"); | ||||||
|  | const { body, validationResult } = require("express-validator"); | ||||||
|  | const { v4: uuidv4 } = require("uuid"); | ||||||
|  | const { authenticateToken } = require("../middleware/auth"); | ||||||
|  | const { requireManageSettings } = require("../middleware/permissions"); | ||||||
|  | const { getSettings, updateSettings } = require("../services/settingsService"); | ||||||
|  | const { queueManager, QUEUE_NAMES } = require("../services/automation"); | ||||||
|  |  | ||||||
|  | const router = express.Router(); | ||||||
|  |  | ||||||
|  | // Get metrics settings | ||||||
|  | router.get("/", authenticateToken, requireManageSettings, async (_req, res) => { | ||||||
|  | 	try { | ||||||
|  | 		const settings = await getSettings(); | ||||||
|  |  | ||||||
|  | 		// Generate anonymous ID if it doesn't exist | ||||||
|  | 		if (!settings.metrics_anonymous_id) { | ||||||
|  | 			const anonymousId = uuidv4(); | ||||||
|  | 			await updateSettings(settings.id, { | ||||||
|  | 				metrics_anonymous_id: anonymousId, | ||||||
|  | 			}); | ||||||
|  | 			settings.metrics_anonymous_id = anonymousId; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		res.json({ | ||||||
|  | 			metrics_enabled: settings.metrics_enabled ?? true, | ||||||
|  | 			metrics_anonymous_id: settings.metrics_anonymous_id, | ||||||
|  | 			metrics_last_sent: settings.metrics_last_sent, | ||||||
|  | 		}); | ||||||
|  | 	} catch (error) { | ||||||
|  | 		console.error("Metrics settings fetch error:", error); | ||||||
|  | 		res.status(500).json({ error: "Failed to fetch metrics settings" }); | ||||||
|  | 	} | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Update metrics settings | ||||||
|  | router.put( | ||||||
|  | 	"/", | ||||||
|  | 	authenticateToken, | ||||||
|  | 	requireManageSettings, | ||||||
|  | 	[ | ||||||
|  | 		body("metrics_enabled") | ||||||
|  | 			.isBoolean() | ||||||
|  | 			.withMessage("Metrics enabled must be a boolean"), | ||||||
|  | 	], | ||||||
|  | 	async (req, res) => { | ||||||
|  | 		try { | ||||||
|  | 			const errors = validationResult(req); | ||||||
|  | 			if (!errors.isEmpty()) { | ||||||
|  | 				return res.status(400).json({ errors: errors.array() }); | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			const { metrics_enabled } = req.body; | ||||||
|  | 			const settings = await getSettings(); | ||||||
|  |  | ||||||
|  | 			await updateSettings(settings.id, { | ||||||
|  | 				metrics_enabled, | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			console.log( | ||||||
|  | 				`Metrics ${metrics_enabled ? "enabled" : "disabled"} by user`, | ||||||
|  | 			); | ||||||
|  |  | ||||||
|  | 			res.json({ | ||||||
|  | 				message: "Metrics settings updated successfully", | ||||||
|  | 				metrics_enabled, | ||||||
|  | 			}); | ||||||
|  | 		} catch (error) { | ||||||
|  | 			console.error("Metrics settings update error:", error); | ||||||
|  | 			res.status(500).json({ error: "Failed to update metrics settings" }); | ||||||
|  | 		} | ||||||
|  | 	}, | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | // Regenerate anonymous ID | ||||||
|  | router.post( | ||||||
|  | 	"/regenerate-id", | ||||||
|  | 	authenticateToken, | ||||||
|  | 	requireManageSettings, | ||||||
|  | 	async (_req, res) => { | ||||||
|  | 		try { | ||||||
|  | 			const settings = await getSettings(); | ||||||
|  | 			const newAnonymousId = uuidv4(); | ||||||
|  |  | ||||||
|  | 			await updateSettings(settings.id, { | ||||||
|  | 				metrics_anonymous_id: newAnonymousId, | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			console.log("Anonymous ID regenerated"); | ||||||
|  |  | ||||||
|  | 			res.json({ | ||||||
|  | 				message: "Anonymous ID regenerated successfully", | ||||||
|  | 				metrics_anonymous_id: newAnonymousId, | ||||||
|  | 			}); | ||||||
|  | 		} catch (error) { | ||||||
|  | 			console.error("Anonymous ID regeneration error:", error); | ||||||
|  | 			res.status(500).json({ error: "Failed to regenerate anonymous ID" }); | ||||||
|  | 		} | ||||||
|  | 	}, | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | // Manually send metrics now | ||||||
|  | router.post( | ||||||
|  | 	"/send-now", | ||||||
|  | 	authenticateToken, | ||||||
|  | 	requireManageSettings, | ||||||
|  | 	async (_req, res) => { | ||||||
|  | 		try { | ||||||
|  | 			const settings = await getSettings(); | ||||||
|  |  | ||||||
|  | 			if (!settings.metrics_enabled) { | ||||||
|  | 				return res.status(400).json({ | ||||||
|  | 					error: "Metrics are disabled. Please enable metrics first.", | ||||||
|  | 				}); | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			// Trigger metrics directly (no queue delay for manual trigger) | ||||||
|  | 			const metricsReporting = | ||||||
|  | 				queueManager.automations[QUEUE_NAMES.METRICS_REPORTING]; | ||||||
|  | 			const result = await metricsReporting.process( | ||||||
|  | 				{ name: "manual-send" }, | ||||||
|  | 				false, | ||||||
|  | 			); | ||||||
|  |  | ||||||
|  | 			if (result.success) { | ||||||
|  | 				console.log("✅ Manual metrics sent successfully"); | ||||||
|  | 				res.json({ | ||||||
|  | 					message: "Metrics sent successfully", | ||||||
|  | 					data: result, | ||||||
|  | 				}); | ||||||
|  | 			} else { | ||||||
|  | 				console.error("❌ Failed to send metrics:", result); | ||||||
|  | 				res.status(500).json({ | ||||||
|  | 					error: "Failed to send metrics", | ||||||
|  | 					details: result.reason || result.error, | ||||||
|  | 				}); | ||||||
|  | 			} | ||||||
|  | 		} catch (error) { | ||||||
|  | 			console.error("Send metrics error:", error); | ||||||
|  | 			res.status(500).json({ | ||||||
|  | 				error: "Failed to send metrics", | ||||||
|  | 				details: error.message, | ||||||
|  | 			}); | ||||||
|  | 		} | ||||||
|  | 	}, | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | module.exports = router; | ||||||
| @@ -101,37 +101,41 @@ router.get("/", async (req, res) => { | |||||||
| 			prisma.packages.count({ where }), | 			prisma.packages.count({ where }), | ||||||
| 		]); | 		]); | ||||||
|  |  | ||||||
| 		// Get additional stats for each package | 		// OPTIMIZATION: Batch query all stats instead of N individual queries | ||||||
| 		const packagesWithStats = await Promise.all( | 		const packageIds = packages.map((pkg) => pkg.id); | ||||||
| 			packages.map(async (pkg) => { |  | ||||||
| 				// Build base where clause for this package |  | ||||||
| 				const baseWhere = { package_id: pkg.id }; |  | ||||||
|  |  | ||||||
| 				// If host filter is specified, add host filter to all queries | 		// Get all counts and host data in 3 batch queries instead of N*3 queries | ||||||
| 				const hostWhere = host ? { ...baseWhere, host_id: host } : baseWhere; | 		const [allUpdatesCounts, allSecurityCounts, allPackageHostsData] = | ||||||
|  | 			await Promise.all([ | ||||||
| 				const [updatesCount, securityCount, packageHosts] = await Promise.all([ | 				// Batch count all packages that need updates | ||||||
| 					prisma.host_packages.count({ | 				prisma.host_packages.groupBy({ | ||||||
|  | 					by: ["package_id"], | ||||||
| 					where: { | 					where: { | ||||||
| 							...hostWhere, | 						package_id: { in: packageIds }, | ||||||
| 						needs_update: true, | 						needs_update: true, | ||||||
|  | 						...(host ? { host_id: host } : {}), | ||||||
| 					}, | 					}, | ||||||
|  | 					_count: { id: true }, | ||||||
| 				}), | 				}), | ||||||
| 					prisma.host_packages.count({ | 				// Batch count all packages with security updates | ||||||
|  | 				prisma.host_packages.groupBy({ | ||||||
|  | 					by: ["package_id"], | ||||||
| 					where: { | 					where: { | ||||||
| 							...hostWhere, | 						package_id: { in: packageIds }, | ||||||
| 						needs_update: true, | 						needs_update: true, | ||||||
| 						is_security_update: true, | 						is_security_update: true, | ||||||
|  | 						...(host ? { host_id: host } : {}), | ||||||
| 					}, | 					}, | ||||||
|  | 					_count: { id: true }, | ||||||
| 				}), | 				}), | ||||||
|  | 				// Batch fetch all host data for packages | ||||||
| 				prisma.host_packages.findMany({ | 				prisma.host_packages.findMany({ | ||||||
| 					where: { | 					where: { | ||||||
| 							...hostWhere, | 						package_id: { in: packageIds }, | ||||||
| 							// If host filter is specified, include all packages for that host | 						...(host ? { host_id: host } : { needs_update: true }), | ||||||
| 							// Otherwise, only include packages that need updates |  | ||||||
| 							...(host ? {} : { needs_update: true }), |  | ||||||
| 					}, | 					}, | ||||||
| 					select: { | 					select: { | ||||||
|  | 						package_id: true, | ||||||
| 						hosts: { | 						hosts: { | ||||||
| 							select: { | 							select: { | ||||||
| 								id: true, | 								id: true, | ||||||
| @@ -145,14 +149,27 @@ router.get("/", async (req, res) => { | |||||||
| 						needs_update: true, | 						needs_update: true, | ||||||
| 						is_security_update: true, | 						is_security_update: true, | ||||||
| 					}, | 					}, | ||||||
| 						take: 10, // Limit to first 10 for performance | 					// Limit to first 10 per package | ||||||
|  | 					take: 100, // Increased from package-based limit | ||||||
| 				}), | 				}), | ||||||
| 			]); | 			]); | ||||||
|  |  | ||||||
| 				return { | 		// Create lookup maps for O(1) access | ||||||
| 					...pkg, | 		const updatesCountMap = new Map( | ||||||
| 					packageHostsCount: pkg._count.host_packages, | 			allUpdatesCounts.map((item) => [item.package_id, item._count.id]), | ||||||
| 					packageHosts: packageHosts.map((hp) => ({ | 		); | ||||||
|  | 		const securityCountMap = new Map( | ||||||
|  | 			allSecurityCounts.map((item) => [item.package_id, item._count.id]), | ||||||
|  | 		); | ||||||
|  | 		const packageHostsMap = new Map(); | ||||||
|  |  | ||||||
|  | 		// Group host data by package_id | ||||||
|  | 		for (const hp of allPackageHostsData) { | ||||||
|  | 			if (!packageHostsMap.has(hp.package_id)) { | ||||||
|  | 				packageHostsMap.set(hp.package_id, []); | ||||||
|  | 			} | ||||||
|  | 			const hosts = packageHostsMap.get(hp.package_id); | ||||||
|  | 			hosts.push({ | ||||||
| 				hostId: hp.hosts.id, | 				hostId: hp.hosts.id, | ||||||
| 				friendlyName: hp.hosts.friendly_name, | 				friendlyName: hp.hosts.friendly_name, | ||||||
| 				osType: hp.hosts.os_type, | 				osType: hp.hosts.os_type, | ||||||
| @@ -160,15 +177,31 @@ router.get("/", async (req, res) => { | |||||||
| 				availableVersion: hp.available_version, | 				availableVersion: hp.available_version, | ||||||
| 				needsUpdate: hp.needs_update, | 				needsUpdate: hp.needs_update, | ||||||
| 				isSecurityUpdate: hp.is_security_update, | 				isSecurityUpdate: hp.is_security_update, | ||||||
| 					})), | 			}); | ||||||
|  |  | ||||||
|  | 			// Limit to 10 hosts per package | ||||||
|  | 			if (hosts.length > 10) { | ||||||
|  | 				packageHostsMap.set(hp.package_id, hosts.slice(0, 10)); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Map packages with stats from lookup maps (no more DB queries!) | ||||||
|  | 		const packagesWithStats = packages.map((pkg) => { | ||||||
|  | 			const updatesCount = updatesCountMap.get(pkg.id) || 0; | ||||||
|  | 			const securityCount = securityCountMap.get(pkg.id) || 0; | ||||||
|  | 			const packageHosts = packageHostsMap.get(pkg.id) || []; | ||||||
|  |  | ||||||
|  | 			return { | ||||||
|  | 				...pkg, | ||||||
|  | 				packageHostsCount: pkg._count.host_packages, | ||||||
|  | 				packageHosts, | ||||||
| 				stats: { | 				stats: { | ||||||
| 					totalInstalls: pkg._count.host_packages, | 					totalInstalls: pkg._count.host_packages, | ||||||
| 					updatesNeeded: updatesCount, | 					updatesNeeded: updatesCount, | ||||||
| 					securityUpdates: securityCount, | 					securityUpdates: securityCount, | ||||||
| 				}, | 				}, | ||||||
| 			}; | 			}; | ||||||
| 			}), | 		}); | ||||||
| 		); |  | ||||||
|  |  | ||||||
| 		res.json({ | 		res.json({ | ||||||
| 			packages: packagesWithStats, | 			packages: packagesWithStats, | ||||||
|   | |||||||
| @@ -158,6 +158,7 @@ router.put( | |||||||
| 				logoDark, | 				logoDark, | ||||||
| 				logoLight, | 				logoLight, | ||||||
| 				favicon, | 				favicon, | ||||||
|  | 				colorTheme, | ||||||
| 			} = req.body; | 			} = req.body; | ||||||
|  |  | ||||||
| 			// Get current settings to check for update interval changes | 			// Get current settings to check for update interval changes | ||||||
| @@ -189,6 +190,7 @@ router.put( | |||||||
| 			if (logoDark !== undefined) updateData.logo_dark = logoDark; | 			if (logoDark !== undefined) updateData.logo_dark = logoDark; | ||||||
| 			if (logoLight !== undefined) updateData.logo_light = logoLight; | 			if (logoLight !== undefined) updateData.logo_light = logoLight; | ||||||
| 			if (favicon !== undefined) updateData.favicon = favicon; | 			if (favicon !== undefined) updateData.favicon = favicon; | ||||||
|  | 			if (colorTheme !== undefined) updateData.color_theme = colorTheme; | ||||||
|  |  | ||||||
| 			const updatedSettings = await updateSettings( | 			const updatedSettings = await updateSettings( | ||||||
| 				currentSettings.id, | 				currentSettings.id, | ||||||
|   | |||||||
| @@ -14,13 +14,16 @@ const router = express.Router(); | |||||||
| function getCurrentVersion() { | function getCurrentVersion() { | ||||||
| 	try { | 	try { | ||||||
| 		const packageJson = require("../../package.json"); | 		const packageJson = require("../../package.json"); | ||||||
| 		return packageJson?.version || "1.3.0"; | 		if (!packageJson?.version) { | ||||||
|  | 			throw new Error("Version not found in package.json"); | ||||||
|  | 		} | ||||||
|  | 		return packageJson.version; | ||||||
| 	} catch (packageError) { | 	} catch (packageError) { | ||||||
| 		console.warn( | 		console.error( | ||||||
| 			"Could not read version from package.json, using fallback:", | 			"Could not read version from package.json:", | ||||||
| 			packageError.message, | 			packageError.message, | ||||||
| 		); | 		); | ||||||
| 		return "1.3.0"; | 		return "unknown"; | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -11,7 +11,31 @@ const { | |||||||
|  |  | ||||||
| const router = express.Router(); | const router = express.Router(); | ||||||
|  |  | ||||||
| // Get WebSocket connection status by api_id (no database access - pure memory lookup) | // Get WebSocket connection status for multiple hosts at once (bulk endpoint) | ||||||
|  | router.get("/status", authenticateToken, async (req, res) => { | ||||||
|  | 	try { | ||||||
|  | 		const { apiIds } = req.query; // Comma-separated list of api_ids | ||||||
|  | 		const idArray = apiIds ? apiIds.split(",").filter((id) => id.trim()) : []; | ||||||
|  |  | ||||||
|  | 		const statusMap = {}; | ||||||
|  | 		idArray.forEach((apiId) => { | ||||||
|  | 			statusMap[apiId] = getConnectionInfo(apiId); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		res.json({ | ||||||
|  | 			success: true, | ||||||
|  | 			data: statusMap, | ||||||
|  | 		}); | ||||||
|  | 	} catch (error) { | ||||||
|  | 		console.error("Error fetching bulk WebSocket status:", error); | ||||||
|  | 		res.status(500).json({ | ||||||
|  | 			success: false, | ||||||
|  | 			error: "Failed to fetch WebSocket status", | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Get WebSocket connection status by api_id (single endpoint) | ||||||
| router.get("/status/:apiId", authenticateToken, async (req, res) => { | router.get("/status/:apiId", authenticateToken, async (req, res) => { | ||||||
| 	try { | 	try { | ||||||
| 		const { apiId } = req.params; | 		const { apiId } = req.params; | ||||||
|   | |||||||
| @@ -66,8 +66,10 @@ const autoEnrollmentRoutes = require("./routes/autoEnrollmentRoutes"); | |||||||
| const gethomepageRoutes = require("./routes/gethomepageRoutes"); | const gethomepageRoutes = require("./routes/gethomepageRoutes"); | ||||||
| const automationRoutes = require("./routes/automationRoutes"); | const automationRoutes = require("./routes/automationRoutes"); | ||||||
| const dockerRoutes = require("./routes/dockerRoutes"); | const dockerRoutes = require("./routes/dockerRoutes"); | ||||||
|  | const integrationRoutes = require("./routes/integrationRoutes"); | ||||||
| const wsRoutes = require("./routes/wsRoutes"); | const wsRoutes = require("./routes/wsRoutes"); | ||||||
| const agentVersionRoutes = require("./routes/agentVersionRoutes"); | const agentVersionRoutes = require("./routes/agentVersionRoutes"); | ||||||
|  | const metricsRoutes = require("./routes/metricsRoutes"); | ||||||
| const { initSettings } = require("./services/settingsService"); | const { initSettings } = require("./services/settingsService"); | ||||||
| const { queueManager } = require("./services/automation"); | const { queueManager } = require("./services/automation"); | ||||||
| const { authenticateToken, requireAdmin } = require("./middleware/auth"); | const { authenticateToken, requireAdmin } = require("./middleware/auth"); | ||||||
| @@ -295,7 +297,7 @@ app.disable("x-powered-by"); | |||||||
| // Rate limiting with monitoring | // Rate limiting with monitoring | ||||||
| const limiter = rateLimit({ | const limiter = rateLimit({ | ||||||
| 	windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 15 * 60 * 1000, | 	windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 15 * 60 * 1000, | ||||||
| 	max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 100, | 	max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 5000, | ||||||
| 	message: { | 	message: { | ||||||
| 		error: "Too many requests from this IP, please try again later.", | 		error: "Too many requests from this IP, please try again later.", | ||||||
| 		retryAfter: Math.ceil( | 		retryAfter: Math.ceil( | ||||||
| @@ -424,7 +426,7 @@ const apiVersion = process.env.API_VERSION || "v1"; | |||||||
| const authLimiter = rateLimit({ | const authLimiter = rateLimit({ | ||||||
| 	windowMs: | 	windowMs: | ||||||
| 		parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 10) || 10 * 60 * 1000, | 		parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 10) || 10 * 60 * 1000, | ||||||
| 	max: parseInt(process.env.AUTH_RATE_LIMIT_MAX, 10) || 20, | 	max: parseInt(process.env.AUTH_RATE_LIMIT_MAX, 10) || 500, | ||||||
| 	message: { | 	message: { | ||||||
| 		error: "Too many authentication requests, please try again later.", | 		error: "Too many authentication requests, please try again later.", | ||||||
| 		retryAfter: Math.ceil( | 		retryAfter: Math.ceil( | ||||||
| @@ -438,7 +440,7 @@ const authLimiter = rateLimit({ | |||||||
| }); | }); | ||||||
| const agentLimiter = rateLimit({ | const agentLimiter = rateLimit({ | ||||||
| 	windowMs: parseInt(process.env.AGENT_RATE_LIMIT_WINDOW_MS, 10) || 60 * 1000, | 	windowMs: parseInt(process.env.AGENT_RATE_LIMIT_WINDOW_MS, 10) || 60 * 1000, | ||||||
| 	max: parseInt(process.env.AGENT_RATE_LIMIT_MAX, 10) || 120, | 	max: parseInt(process.env.AGENT_RATE_LIMIT_MAX, 10) || 1000, | ||||||
| 	message: { | 	message: { | ||||||
| 		error: "Too many agent requests, please try again later.", | 		error: "Too many agent requests, please try again later.", | ||||||
| 		retryAfter: Math.ceil( | 		retryAfter: Math.ceil( | ||||||
| @@ -471,8 +473,10 @@ app.use( | |||||||
| app.use(`/api/${apiVersion}/gethomepage`, gethomepageRoutes); | app.use(`/api/${apiVersion}/gethomepage`, gethomepageRoutes); | ||||||
| app.use(`/api/${apiVersion}/automation`, automationRoutes); | app.use(`/api/${apiVersion}/automation`, automationRoutes); | ||||||
| app.use(`/api/${apiVersion}/docker`, dockerRoutes); | app.use(`/api/${apiVersion}/docker`, dockerRoutes); | ||||||
|  | app.use(`/api/${apiVersion}/integrations`, integrationRoutes); | ||||||
| app.use(`/api/${apiVersion}/ws`, wsRoutes); | app.use(`/api/${apiVersion}/ws`, wsRoutes); | ||||||
| app.use(`/api/${apiVersion}/agent`, agentVersionRoutes); | app.use(`/api/${apiVersion}/agent`, agentVersionRoutes); | ||||||
|  | app.use(`/api/${apiVersion}/metrics`, metricsRoutes); | ||||||
|  |  | ||||||
| // Bull Board - will be populated after queue manager initializes | // Bull Board - will be populated after queue manager initializes | ||||||
| let bullBoardRouter = null; | let bullBoardRouter = null; | ||||||
| @@ -1198,6 +1202,15 @@ async function startServer() { | |||||||
| 		initAgentWs(server, prisma); | 		initAgentWs(server, prisma); | ||||||
| 		await agentVersionService.initialize(); | 		await agentVersionService.initialize(); | ||||||
|  |  | ||||||
|  | 		// Send metrics on startup (silent - no console output) | ||||||
|  | 		try { | ||||||
|  | 			const metricsReporting = | ||||||
|  | 				queueManager.automations[QUEUE_NAMES.METRICS_REPORTING]; | ||||||
|  | 			await metricsReporting.sendSilent(); | ||||||
|  | 		} catch (_error) { | ||||||
|  | 			// Silent failure - don't block server startup if metrics fail | ||||||
|  | 		} | ||||||
|  |  | ||||||
| 		server.listen(PORT, () => { | 		server.listen(PORT, () => { | ||||||
| 			if (process.env.ENABLE_LOGGING === "true") { | 			if (process.env.ENABLE_LOGGING === "true") { | ||||||
| 				logger.info(`Server running on port ${PORT}`); | 				logger.info(`Server running on port ${PORT}`); | ||||||
|   | |||||||
| @@ -428,26 +428,29 @@ class AgentVersionService { | |||||||
| 	async getVersionInfo() { | 	async getVersionInfo() { | ||||||
| 		let hasUpdate = false; | 		let hasUpdate = false; | ||||||
| 		let updateStatus = "unknown"; | 		let updateStatus = "unknown"; | ||||||
| 		let effectiveLatestVersion = this.currentVersion; // Always use local version if available |  | ||||||
|  |  | ||||||
| 		// If we have a local version, use it as the latest regardless of GitHub | 		// Latest version should ALWAYS come from GitHub, not from local binaries | ||||||
| 		if (this.currentVersion) { | 		// currentVersion = what's installed locally | ||||||
| 			effectiveLatestVersion = this.currentVersion; | 		// latestVersion = what's available on GitHub | ||||||
|  | 		if (this.latestVersion) { | ||||||
|  | 			console.log(`📦 Latest version from GitHub: ${this.latestVersion}`); | ||||||
|  | 		} else { | ||||||
| 			console.log( | 			console.log( | ||||||
| 				`🔄 Using local agent version ${this.currentVersion} as latest`, | 				`⚠️ No GitHub release version available (API may be unavailable)`, | ||||||
| 			); |  | ||||||
| 		} else if (this.latestVersion) { |  | ||||||
| 			// Fallback to GitHub version only if no local version |  | ||||||
| 			effectiveLatestVersion = this.latestVersion; |  | ||||||
| 			console.log( |  | ||||||
| 				`🔄 No local version found, using GitHub version ${this.latestVersion}`, |  | ||||||
| 			); | 			); | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if (this.currentVersion && effectiveLatestVersion) { | 		if (this.currentVersion) { | ||||||
|  | 			console.log(`💾 Current local agent version: ${this.currentVersion}`); | ||||||
|  | 		} else { | ||||||
|  | 			console.log(`⚠️ No local agent binary found`); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Determine update status by comparing current vs latest (from GitHub) | ||||||
|  | 		if (this.currentVersion && this.latestVersion) { | ||||||
| 			const comparison = compareVersions( | 			const comparison = compareVersions( | ||||||
| 				this.currentVersion, | 				this.currentVersion, | ||||||
| 				effectiveLatestVersion, | 				this.latestVersion, | ||||||
| 			); | 			); | ||||||
| 			if (comparison < 0) { | 			if (comparison < 0) { | ||||||
| 				hasUpdate = true; | 				hasUpdate = true; | ||||||
| @@ -459,25 +462,25 @@ class AgentVersionService { | |||||||
| 				hasUpdate = false; | 				hasUpdate = false; | ||||||
| 				updateStatus = "up-to-date"; | 				updateStatus = "up-to-date"; | ||||||
| 			} | 			} | ||||||
| 		} else if (effectiveLatestVersion && !this.currentVersion) { | 		} else if (this.latestVersion && !this.currentVersion) { | ||||||
| 			hasUpdate = true; | 			hasUpdate = true; | ||||||
| 			updateStatus = "no-agent"; | 			updateStatus = "no-agent"; | ||||||
| 		} else if (this.currentVersion && !effectiveLatestVersion) { | 		} else if (this.currentVersion && !this.latestVersion) { | ||||||
| 			// We have a current version but no latest version (GitHub API unavailable) | 			// We have a current version but no latest version (GitHub API unavailable) | ||||||
| 			hasUpdate = false; | 			hasUpdate = false; | ||||||
| 			updateStatus = "github-unavailable"; | 			updateStatus = "github-unavailable"; | ||||||
| 		} else if (!this.currentVersion && !effectiveLatestVersion) { | 		} else if (!this.currentVersion && !this.latestVersion) { | ||||||
| 			updateStatus = "no-data"; | 			updateStatus = "no-data"; | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		return { | 		return { | ||||||
| 			currentVersion: this.currentVersion, | 			currentVersion: this.currentVersion, | ||||||
| 			latestVersion: effectiveLatestVersion, | 			latestVersion: this.latestVersion, // Always return GitHub version, not local | ||||||
| 			hasUpdate: hasUpdate, | 			hasUpdate: hasUpdate, | ||||||
| 			updateStatus: updateStatus, | 			updateStatus: updateStatus, | ||||||
| 			lastChecked: this.lastChecked, | 			lastChecked: this.lastChecked, | ||||||
| 			supportedArchitectures: this.supportedArchitectures, | 			supportedArchitectures: this.supportedArchitectures, | ||||||
| 			status: effectiveLatestVersion ? "ready" : "no-releases", | 			status: this.latestVersion ? "ready" : "no-releases", | ||||||
| 		}; | 		}; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|   | |||||||
| @@ -99,8 +99,22 @@ function init(server, prismaClient) { | |||||||
| 				// Notify subscribers of connection | 				// Notify subscribers of connection | ||||||
| 				notifyConnectionChange(apiId, true); | 				notifyConnectionChange(apiId, true); | ||||||
|  |  | ||||||
| 				ws.on("message", () => { | 				ws.on("message", async (data) => { | ||||||
| 					// Currently we don't need to handle agent->server messages | 					// Handle incoming messages from agent (e.g., Docker status updates) | ||||||
|  | 					try { | ||||||
|  | 						const message = JSON.parse(data.toString()); | ||||||
|  |  | ||||||
|  | 						if (message.type === "docker_status") { | ||||||
|  | 							// Handle Docker container status events | ||||||
|  | 							await handleDockerStatusEvent(apiId, message); | ||||||
|  | 						} | ||||||
|  | 						// Add more message types here as needed | ||||||
|  | 					} catch (err) { | ||||||
|  | 						console.error( | ||||||
|  | 							`[agent-ws] error parsing message from ${apiId}:`, | ||||||
|  | 							err, | ||||||
|  | 						); | ||||||
|  | 					} | ||||||
| 				}); | 				}); | ||||||
|  |  | ||||||
| 				ws.on("close", () => { | 				ws.on("close", () => { | ||||||
| @@ -162,6 +176,15 @@ function pushSettingsUpdate(apiId, newInterval) { | |||||||
| 	); | 	); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | function pushUpdateAgent(apiId) { | ||||||
|  | 	const ws = apiIdToSocket.get(apiId); | ||||||
|  | 	safeSend(ws, JSON.stringify({ type: "update_agent" })); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function getConnectionByApiId(apiId) { | ||||||
|  | 	return apiIdToSocket.get(apiId); | ||||||
|  | } | ||||||
|  |  | ||||||
| function pushUpdateNotification(apiId, updateInfo) { | function pushUpdateNotification(apiId, updateInfo) { | ||||||
| 	const ws = apiIdToSocket.get(apiId); | 	const ws = apiIdToSocket.get(apiId); | ||||||
| 	if (ws && ws.readyState === WebSocket.OPEN) { | 	if (ws && ws.readyState === WebSocket.OPEN) { | ||||||
| @@ -255,15 +278,73 @@ function subscribeToConnectionChanges(apiId, callback) { | |||||||
| 	}; | 	}; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // Handle Docker container status events from agent | ||||||
|  | async function handleDockerStatusEvent(apiId, message) { | ||||||
|  | 	try { | ||||||
|  | 		const { event: _event, container_id, name, status, timestamp } = message; | ||||||
|  |  | ||||||
|  | 		console.log( | ||||||
|  | 			`[Docker Event] ${apiId}: Container ${name} (${container_id}) - ${status}`, | ||||||
|  | 		); | ||||||
|  |  | ||||||
|  | 		// Find the host | ||||||
|  | 		const host = await prisma.hosts.findUnique({ | ||||||
|  | 			where: { api_id: apiId }, | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		if (!host) { | ||||||
|  | 			console.error(`[Docker Event] Host not found for api_id: ${apiId}`); | ||||||
|  | 			return; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Update container status in database | ||||||
|  | 		const container = await prisma.docker_containers.findUnique({ | ||||||
|  | 			where: { | ||||||
|  | 				host_id_container_id: { | ||||||
|  | 					host_id: host.id, | ||||||
|  | 					container_id: container_id, | ||||||
|  | 				}, | ||||||
|  | 			}, | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		if (container) { | ||||||
|  | 			await prisma.docker_containers.update({ | ||||||
|  | 				where: { id: container.id }, | ||||||
|  | 				data: { | ||||||
|  | 					status: status, | ||||||
|  | 					state: status, | ||||||
|  | 					updated_at: new Date(timestamp || Date.now()), | ||||||
|  | 					last_checked: new Date(), | ||||||
|  | 				}, | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			console.log( | ||||||
|  | 				`[Docker Event] Updated container ${name} status to ${status}`, | ||||||
|  | 			); | ||||||
|  | 		} else { | ||||||
|  | 			console.log( | ||||||
|  | 				`[Docker Event] Container ${name} not found in database (may be new)`, | ||||||
|  | 			); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// TODO: Broadcast to connected dashboard clients via SSE or WebSocket | ||||||
|  | 		// This would notify the frontend UI in real-time | ||||||
|  | 	} catch (error) { | ||||||
|  | 		console.error(`[Docker Event] Error handling Docker status event:`, error); | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
| module.exports = { | module.exports = { | ||||||
| 	init, | 	init, | ||||||
| 	broadcastSettingsUpdate, | 	broadcastSettingsUpdate, | ||||||
| 	pushReportNow, | 	pushReportNow, | ||||||
| 	pushSettingsUpdate, | 	pushSettingsUpdate, | ||||||
|  | 	pushUpdateAgent, | ||||||
| 	pushUpdateNotification, | 	pushUpdateNotification, | ||||||
| 	pushUpdateNotificationToAll, | 	pushUpdateNotificationToAll, | ||||||
| 	// Expose read-only view of connected agents | 	// Expose read-only view of connected agents | ||||||
| 	getConnectedApiIds: () => Array.from(apiIdToSocket.keys()), | 	getConnectedApiIds: () => Array.from(apiIdToSocket.keys()), | ||||||
|  | 	getConnectionByApiId, | ||||||
| 	isConnected: (apiId) => { | 	isConnected: (apiId) => { | ||||||
| 		const ws = apiIdToSocket.get(apiId); | 		const ws = apiIdToSocket.get(apiId); | ||||||
| 		return !!ws && ws.readyState === WebSocket.OPEN; | 		return !!ws && ws.readyState === WebSocket.OPEN; | ||||||
|   | |||||||
							
								
								
									
										164
									
								
								backend/src/services/automation/dockerInventoryCleanup.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								backend/src/services/automation/dockerInventoryCleanup.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,164 @@ | |||||||
|  | const { prisma } = require("./shared/prisma"); | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Docker Inventory Cleanup Automation | ||||||
|  |  * Removes Docker containers and images for hosts that no longer exist | ||||||
|  |  */ | ||||||
|  | class DockerInventoryCleanup { | ||||||
|  | 	constructor(queueManager) { | ||||||
|  | 		this.queueManager = queueManager; | ||||||
|  | 		this.queueName = "docker-inventory-cleanup"; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	/** | ||||||
|  | 	 * Process Docker inventory cleanup job | ||||||
|  | 	 */ | ||||||
|  | 	async process(_job) { | ||||||
|  | 		const startTime = Date.now(); | ||||||
|  | 		console.log("🧹 Starting Docker inventory cleanup..."); | ||||||
|  |  | ||||||
|  | 		try { | ||||||
|  | 			// Step 1: Find and delete orphaned containers (containers for non-existent hosts) | ||||||
|  | 			const orphanedContainers = await prisma.docker_containers.findMany({ | ||||||
|  | 				where: { | ||||||
|  | 					host_id: { | ||||||
|  | 						// Find containers where the host doesn't exist | ||||||
|  | 						notIn: await prisma.hosts | ||||||
|  | 							.findMany({ select: { id: true } }) | ||||||
|  | 							.then((hosts) => hosts.map((h) => h.id)), | ||||||
|  | 					}, | ||||||
|  | 				}, | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			let deletedContainersCount = 0; | ||||||
|  | 			const deletedContainers = []; | ||||||
|  |  | ||||||
|  | 			for (const container of orphanedContainers) { | ||||||
|  | 				try { | ||||||
|  | 					await prisma.docker_containers.delete({ | ||||||
|  | 						where: { id: container.id }, | ||||||
|  | 					}); | ||||||
|  | 					deletedContainersCount++; | ||||||
|  | 					deletedContainers.push({ | ||||||
|  | 						id: container.id, | ||||||
|  | 						container_id: container.container_id, | ||||||
|  | 						name: container.name, | ||||||
|  | 						image_name: container.image_name, | ||||||
|  | 						host_id: container.host_id, | ||||||
|  | 					}); | ||||||
|  | 					console.log( | ||||||
|  | 						`🗑️ Deleted orphaned container: ${container.name} (host_id: ${container.host_id})`, | ||||||
|  | 					); | ||||||
|  | 				} catch (deleteError) { | ||||||
|  | 					console.error( | ||||||
|  | 						`❌ Failed to delete container ${container.id}:`, | ||||||
|  | 						deleteError.message, | ||||||
|  | 					); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			// Step 2: Find and delete orphaned images (images with no containers using them) | ||||||
|  | 			const orphanedImages = await prisma.docker_images.findMany({ | ||||||
|  | 				where: { | ||||||
|  | 					docker_containers: { | ||||||
|  | 						none: {}, | ||||||
|  | 					}, | ||||||
|  | 				}, | ||||||
|  | 				include: { | ||||||
|  | 					_count: { | ||||||
|  | 						select: { | ||||||
|  | 							docker_containers: true, | ||||||
|  | 							docker_image_updates: true, | ||||||
|  | 						}, | ||||||
|  | 					}, | ||||||
|  | 				}, | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			let deletedImagesCount = 0; | ||||||
|  | 			const deletedImages = []; | ||||||
|  |  | ||||||
|  | 			for (const image of orphanedImages) { | ||||||
|  | 				try { | ||||||
|  | 					// First delete any image updates associated with this image | ||||||
|  | 					if (image._count.docker_image_updates > 0) { | ||||||
|  | 						await prisma.docker_image_updates.deleteMany({ | ||||||
|  | 							where: { image_id: image.id }, | ||||||
|  | 						}); | ||||||
|  | 					} | ||||||
|  |  | ||||||
|  | 					// Then delete the image itself | ||||||
|  | 					await prisma.docker_images.delete({ | ||||||
|  | 						where: { id: image.id }, | ||||||
|  | 					}); | ||||||
|  | 					deletedImagesCount++; | ||||||
|  | 					deletedImages.push({ | ||||||
|  | 						id: image.id, | ||||||
|  | 						repository: image.repository, | ||||||
|  | 						tag: image.tag, | ||||||
|  | 						image_id: image.image_id, | ||||||
|  | 					}); | ||||||
|  | 					console.log( | ||||||
|  | 						`🗑️ Deleted orphaned image: ${image.repository}:${image.tag}`, | ||||||
|  | 					); | ||||||
|  | 				} catch (deleteError) { | ||||||
|  | 					console.error( | ||||||
|  | 						`❌ Failed to delete image ${image.id}:`, | ||||||
|  | 						deleteError.message, | ||||||
|  | 					); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			const executionTime = Date.now() - startTime; | ||||||
|  | 			console.log( | ||||||
|  | 				`✅ Docker inventory cleanup completed in ${executionTime}ms - Deleted ${deletedContainersCount} containers and ${deletedImagesCount} images`, | ||||||
|  | 			); | ||||||
|  |  | ||||||
|  | 			return { | ||||||
|  | 				success: true, | ||||||
|  | 				deletedContainersCount, | ||||||
|  | 				deletedImagesCount, | ||||||
|  | 				deletedContainers, | ||||||
|  | 				deletedImages, | ||||||
|  | 				executionTime, | ||||||
|  | 			}; | ||||||
|  | 		} catch (error) { | ||||||
|  | 			const executionTime = Date.now() - startTime; | ||||||
|  | 			console.error( | ||||||
|  | 				`❌ Docker inventory cleanup failed after ${executionTime}ms:`, | ||||||
|  | 				error.message, | ||||||
|  | 			); | ||||||
|  | 			throw error; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	/** | ||||||
|  | 	 * Schedule recurring Docker inventory cleanup (daily at 4 AM) | ||||||
|  | 	 */ | ||||||
|  | 	async schedule() { | ||||||
|  | 		const job = await this.queueManager.queues[this.queueName].add( | ||||||
|  | 			"docker-inventory-cleanup", | ||||||
|  | 			{}, | ||||||
|  | 			{ | ||||||
|  | 				repeat: { cron: "0 4 * * *" }, // Daily at 4 AM | ||||||
|  | 				jobId: "docker-inventory-cleanup-recurring", | ||||||
|  | 			}, | ||||||
|  | 		); | ||||||
|  | 		console.log("✅ Docker inventory cleanup scheduled"); | ||||||
|  | 		return job; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	/** | ||||||
|  | 	 * Trigger manual Docker inventory cleanup | ||||||
|  | 	 */ | ||||||
|  | 	async triggerManual() { | ||||||
|  | 		const job = await this.queueManager.queues[this.queueName].add( | ||||||
|  | 			"docker-inventory-cleanup-manual", | ||||||
|  | 			{}, | ||||||
|  | 			{ priority: 1 }, | ||||||
|  | 		); | ||||||
|  | 		console.log("✅ Manual Docker inventory cleanup triggered"); | ||||||
|  | 		return job; | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | module.exports = DockerInventoryCleanup; | ||||||
| @@ -52,17 +52,24 @@ class GitHubUpdateCheck { | |||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 			// Read version from package.json | 			// Read version from package.json | ||||||
| 			let currentVersion = "1.3.0"; // fallback | 			let currentVersion = null; | ||||||
| 			try { | 			try { | ||||||
| 				const packageJson = require("../../../package.json"); | 				const packageJson = require("../../../package.json"); | ||||||
| 				if (packageJson?.version) { | 				if (packageJson?.version) { | ||||||
| 					currentVersion = packageJson.version; | 					currentVersion = packageJson.version; | ||||||
| 				} | 				} | ||||||
| 			} catch (packageError) { | 			} catch (packageError) { | ||||||
| 				console.warn( | 				console.error( | ||||||
| 					"Could not read version from package.json:", | 					"Could not read version from package.json:", | ||||||
| 					packageError.message, | 					packageError.message, | ||||||
| 				); | 				); | ||||||
|  | 				throw new Error( | ||||||
|  | 					"Could not determine current version from package.json", | ||||||
|  | 				); | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			if (!currentVersion) { | ||||||
|  | 				throw new Error("Version not found in package.json"); | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 			const isUpdateAvailable = | 			const isUpdateAvailable = | ||||||
|   | |||||||
| @@ -8,6 +8,8 @@ const GitHubUpdateCheck = require("./githubUpdateCheck"); | |||||||
| const SessionCleanup = require("./sessionCleanup"); | const SessionCleanup = require("./sessionCleanup"); | ||||||
| const OrphanedRepoCleanup = require("./orphanedRepoCleanup"); | const OrphanedRepoCleanup = require("./orphanedRepoCleanup"); | ||||||
| const OrphanedPackageCleanup = require("./orphanedPackageCleanup"); | const OrphanedPackageCleanup = require("./orphanedPackageCleanup"); | ||||||
|  | const DockerInventoryCleanup = require("./dockerInventoryCleanup"); | ||||||
|  | const MetricsReporting = require("./metricsReporting"); | ||||||
|  |  | ||||||
| // Queue names | // Queue names | ||||||
| const QUEUE_NAMES = { | const QUEUE_NAMES = { | ||||||
| @@ -15,6 +17,8 @@ const QUEUE_NAMES = { | |||||||
| 	SESSION_CLEANUP: "session-cleanup", | 	SESSION_CLEANUP: "session-cleanup", | ||||||
| 	ORPHANED_REPO_CLEANUP: "orphaned-repo-cleanup", | 	ORPHANED_REPO_CLEANUP: "orphaned-repo-cleanup", | ||||||
| 	ORPHANED_PACKAGE_CLEANUP: "orphaned-package-cleanup", | 	ORPHANED_PACKAGE_CLEANUP: "orphaned-package-cleanup", | ||||||
|  | 	DOCKER_INVENTORY_CLEANUP: "docker-inventory-cleanup", | ||||||
|  | 	METRICS_REPORTING: "metrics-reporting", | ||||||
| 	AGENT_COMMANDS: "agent-commands", | 	AGENT_COMMANDS: "agent-commands", | ||||||
| }; | }; | ||||||
|  |  | ||||||
| @@ -91,6 +95,11 @@ class QueueManager { | |||||||
| 			new OrphanedRepoCleanup(this); | 			new OrphanedRepoCleanup(this); | ||||||
| 		this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP] = | 		this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP] = | ||||||
| 			new OrphanedPackageCleanup(this); | 			new OrphanedPackageCleanup(this); | ||||||
|  | 		this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP] = | ||||||
|  | 			new DockerInventoryCleanup(this); | ||||||
|  | 		this.automations[QUEUE_NAMES.METRICS_REPORTING] = new MetricsReporting( | ||||||
|  | 			this, | ||||||
|  | 		); | ||||||
|  |  | ||||||
| 		console.log("✅ All automation classes initialized"); | 		console.log("✅ All automation classes initialized"); | ||||||
| 	} | 	} | ||||||
| @@ -149,6 +158,24 @@ class QueueManager { | |||||||
| 			workerOptions, | 			workerOptions, | ||||||
| 		); | 		); | ||||||
|  |  | ||||||
|  | 		// Docker Inventory Cleanup Worker | ||||||
|  | 		this.workers[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP] = new Worker( | ||||||
|  | 			QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP, | ||||||
|  | 			this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].process.bind( | ||||||
|  | 				this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP], | ||||||
|  | 			), | ||||||
|  | 			workerOptions, | ||||||
|  | 		); | ||||||
|  |  | ||||||
|  | 		// Metrics Reporting Worker | ||||||
|  | 		this.workers[QUEUE_NAMES.METRICS_REPORTING] = new Worker( | ||||||
|  | 			QUEUE_NAMES.METRICS_REPORTING, | ||||||
|  | 			this.automations[QUEUE_NAMES.METRICS_REPORTING].process.bind( | ||||||
|  | 				this.automations[QUEUE_NAMES.METRICS_REPORTING], | ||||||
|  | 			), | ||||||
|  | 			workerOptions, | ||||||
|  | 		); | ||||||
|  |  | ||||||
| 		// Agent Commands Worker | 		// Agent Commands Worker | ||||||
| 		this.workers[QUEUE_NAMES.AGENT_COMMANDS] = new Worker( | 		this.workers[QUEUE_NAMES.AGENT_COMMANDS] = new Worker( | ||||||
| 			QUEUE_NAMES.AGENT_COMMANDS, | 			QUEUE_NAMES.AGENT_COMMANDS, | ||||||
| @@ -163,6 +190,19 @@ class QueueManager { | |||||||
| 					// For settings update, we need additional data | 					// For settings update, we need additional data | ||||||
| 					const { update_interval } = job.data; | 					const { update_interval } = job.data; | ||||||
| 					agentWs.pushSettingsUpdate(api_id, update_interval); | 					agentWs.pushSettingsUpdate(api_id, update_interval); | ||||||
|  | 				} else if (type === "update_agent") { | ||||||
|  | 					// Force agent to update by sending WebSocket command | ||||||
|  | 					const ws = agentWs.getConnectionByApiId(api_id); | ||||||
|  | 					if (ws && ws.readyState === 1) { | ||||||
|  | 						// WebSocket.OPEN | ||||||
|  | 						agentWs.pushUpdateAgent(api_id); | ||||||
|  | 						console.log(`✅ Update command sent to agent ${api_id}`); | ||||||
|  | 					} else { | ||||||
|  | 						console.error(`❌ Agent ${api_id} is not connected`); | ||||||
|  | 						throw new Error( | ||||||
|  | 							`Agent ${api_id} is not connected. Cannot send update command.`, | ||||||
|  | 						); | ||||||
|  | 					} | ||||||
| 				} else { | 				} else { | ||||||
| 					console.error(`Unknown agent command type: ${type}`); | 					console.error(`Unknown agent command type: ${type}`); | ||||||
| 				} | 				} | ||||||
| @@ -205,6 +245,8 @@ class QueueManager { | |||||||
| 		await this.automations[QUEUE_NAMES.SESSION_CLEANUP].schedule(); | 		await this.automations[QUEUE_NAMES.SESSION_CLEANUP].schedule(); | ||||||
| 		await this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].schedule(); | 		await this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].schedule(); | ||||||
| 		await this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].schedule(); | 		await this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].schedule(); | ||||||
|  | 		await this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].schedule(); | ||||||
|  | 		await this.automations[QUEUE_NAMES.METRICS_REPORTING].schedule(); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	/** | 	/** | ||||||
| @@ -228,6 +270,16 @@ class QueueManager { | |||||||
| 		].triggerManual(); | 		].triggerManual(); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	async triggerDockerInventoryCleanup() { | ||||||
|  | 		return this.automations[ | ||||||
|  | 			QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP | ||||||
|  | 		].triggerManual(); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	async triggerMetricsReporting() { | ||||||
|  | 		return this.automations[QUEUE_NAMES.METRICS_REPORTING].triggerManual(); | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	/** | 	/** | ||||||
| 	 * Get queue statistics | 	 * Get queue statistics | ||||||
| 	 */ | 	 */ | ||||||
|   | |||||||
							
								
								
									
										172
									
								
								backend/src/services/automation/metricsReporting.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								backend/src/services/automation/metricsReporting.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,172 @@ | |||||||
|  | const axios = require("axios"); | ||||||
|  | const { prisma } = require("./shared/prisma"); | ||||||
|  | const { updateSettings } = require("../../services/settingsService"); | ||||||
|  |  | ||||||
|  | const METRICS_API_URL = | ||||||
|  | 	process.env.METRICS_API_URL || "https://metrics.patchmon.cloud"; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Metrics Reporting Automation | ||||||
|  |  * Sends anonymous usage metrics every 24 hours | ||||||
|  |  */ | ||||||
|  | class MetricsReporting { | ||||||
|  | 	constructor(queueManager) { | ||||||
|  | 		this.queueManager = queueManager; | ||||||
|  | 		this.queueName = "metrics-reporting"; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	/** | ||||||
|  | 	 * Process metrics reporting job | ||||||
|  | 	 */ | ||||||
|  | 	async process(_job, silent = false) { | ||||||
|  | 		const startTime = Date.now(); | ||||||
|  | 		if (!silent) console.log("📊 Starting metrics reporting..."); | ||||||
|  |  | ||||||
|  | 		try { | ||||||
|  | 			// Fetch fresh settings directly from database (bypass cache) | ||||||
|  | 			const settings = await prisma.settings.findFirst({ | ||||||
|  | 				orderBy: { updated_at: "desc" }, | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			// Check if metrics are enabled | ||||||
|  | 			if (settings.metrics_enabled !== true) { | ||||||
|  | 				if (!silent) console.log("📊 Metrics reporting is disabled"); | ||||||
|  | 				return { success: false, reason: "disabled" }; | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			// Check if we have an anonymous ID | ||||||
|  | 			if (!settings.metrics_anonymous_id) { | ||||||
|  | 				if (!silent) console.log("📊 No anonymous ID found, skipping metrics"); | ||||||
|  | 				return { success: false, reason: "no_id" }; | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			// Get host count | ||||||
|  | 			const hostCount = await prisma.hosts.count(); | ||||||
|  |  | ||||||
|  | 			// Get version | ||||||
|  | 			const packageJson = require("../../../package.json"); | ||||||
|  | 			const version = packageJson.version; | ||||||
|  |  | ||||||
|  | 			// Prepare metrics data | ||||||
|  | 			const metricsData = { | ||||||
|  | 				anonymous_id: settings.metrics_anonymous_id, | ||||||
|  | 				host_count: hostCount, | ||||||
|  | 				version, | ||||||
|  | 			}; | ||||||
|  |  | ||||||
|  | 			if (!silent) | ||||||
|  | 				console.log( | ||||||
|  | 					`📊 Sending metrics: ${hostCount} hosts, version ${version}`, | ||||||
|  | 				); | ||||||
|  |  | ||||||
|  | 			// Send to metrics API | ||||||
|  | 			try { | ||||||
|  | 				const response = await axios.post( | ||||||
|  | 					`${METRICS_API_URL}/metrics/submit`, | ||||||
|  | 					metricsData, | ||||||
|  | 					{ | ||||||
|  | 						timeout: 10000, | ||||||
|  | 						headers: { | ||||||
|  | 							"Content-Type": "application/json", | ||||||
|  | 						}, | ||||||
|  | 					}, | ||||||
|  | 				); | ||||||
|  |  | ||||||
|  | 				// Update last sent timestamp | ||||||
|  | 				await updateSettings(settings.id, { | ||||||
|  | 					metrics_last_sent: new Date(), | ||||||
|  | 				}); | ||||||
|  |  | ||||||
|  | 				const executionTime = Date.now() - startTime; | ||||||
|  | 				if (!silent) | ||||||
|  | 					console.log( | ||||||
|  | 						`✅ Metrics sent successfully in ${executionTime}ms:`, | ||||||
|  | 						response.data, | ||||||
|  | 					); | ||||||
|  |  | ||||||
|  | 				return { | ||||||
|  | 					success: true, | ||||||
|  | 					data: response.data, | ||||||
|  | 					hostCount, | ||||||
|  | 					version, | ||||||
|  | 					executionTime, | ||||||
|  | 				}; | ||||||
|  | 			} catch (apiError) { | ||||||
|  | 				const executionTime = Date.now() - startTime; | ||||||
|  | 				if (!silent) | ||||||
|  | 					console.error( | ||||||
|  | 						`❌ Failed to send metrics to API after ${executionTime}ms:`, | ||||||
|  | 						apiError.message, | ||||||
|  | 					); | ||||||
|  | 				return { | ||||||
|  | 					success: false, | ||||||
|  | 					reason: "api_error", | ||||||
|  | 					error: apiError.message, | ||||||
|  | 					executionTime, | ||||||
|  | 				}; | ||||||
|  | 			} | ||||||
|  | 		} catch (error) { | ||||||
|  | 			const executionTime = Date.now() - startTime; | ||||||
|  | 			if (!silent) | ||||||
|  | 				console.error( | ||||||
|  | 					`❌ Error in metrics reporting after ${executionTime}ms:`, | ||||||
|  | 					error.message, | ||||||
|  | 				); | ||||||
|  | 			// Don't throw on silent mode, just return failure | ||||||
|  | 			if (silent) { | ||||||
|  | 				return { | ||||||
|  | 					success: false, | ||||||
|  | 					reason: "error", | ||||||
|  | 					error: error.message, | ||||||
|  | 					executionTime, | ||||||
|  | 				}; | ||||||
|  | 			} | ||||||
|  | 			throw error; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	/** | ||||||
|  | 	 * Schedule recurring metrics reporting (daily at 2 AM) | ||||||
|  | 	 */ | ||||||
|  | 	async schedule() { | ||||||
|  | 		const job = await this.queueManager.queues[this.queueName].add( | ||||||
|  | 			"metrics-reporting", | ||||||
|  | 			{}, | ||||||
|  | 			{ | ||||||
|  | 				repeat: { cron: "0 2 * * *" }, // Daily at 2 AM | ||||||
|  | 				jobId: "metrics-reporting-recurring", | ||||||
|  | 			}, | ||||||
|  | 		); | ||||||
|  | 		console.log("✅ Metrics reporting scheduled (daily at 2 AM)"); | ||||||
|  | 		return job; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	/** | ||||||
|  | 	 * Trigger manual metrics reporting | ||||||
|  | 	 */ | ||||||
|  | 	async triggerManual() { | ||||||
|  | 		const job = await this.queueManager.queues[this.queueName].add( | ||||||
|  | 			"metrics-reporting-manual", | ||||||
|  | 			{}, | ||||||
|  | 			{ priority: 1 }, | ||||||
|  | 		); | ||||||
|  | 		console.log("✅ Manual metrics reporting triggered"); | ||||||
|  | 		return job; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	/** | ||||||
|  | 	 * Send metrics immediately (silent mode) | ||||||
|  | 	 * Used for automatic sending on server startup | ||||||
|  | 	 */ | ||||||
|  | 	async sendSilent() { | ||||||
|  | 		try { | ||||||
|  | 			const result = await this.process({ name: "startup-silent" }, true); | ||||||
|  | 			return result; | ||||||
|  | 		} catch (error) { | ||||||
|  | 			// Silent failure on startup | ||||||
|  | 			return { success: false, reason: "error", error: error.message }; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | module.exports = MetricsReporting; | ||||||
| @@ -33,7 +33,8 @@ async function checkPublicRepo(owner, repo) { | |||||||
| 	try { | 	try { | ||||||
| 		const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`; | 		const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`; | ||||||
|  |  | ||||||
| 		let currentVersion = "1.3.0"; // fallback | 		// Get current version for User-Agent (or use generic if unavailable) | ||||||
|  | 		let currentVersion = "unknown"; | ||||||
| 		try { | 		try { | ||||||
| 			const packageJson = require("../../../package.json"); | 			const packageJson = require("../../../package.json"); | ||||||
| 			if (packageJson?.version) { | 			if (packageJson?.version) { | ||||||
| @@ -41,7 +42,7 @@ async function checkPublicRepo(owner, repo) { | |||||||
| 			} | 			} | ||||||
| 		} catch (packageError) { | 		} catch (packageError) { | ||||||
| 			console.warn( | 			console.warn( | ||||||
| 				"Could not read version from package.json for User-Agent, using fallback:", | 				"Could not read version from package.json for User-Agent:", | ||||||
| 				packageError.message, | 				packageError.message, | ||||||
| 			); | 			); | ||||||
| 		} | 		} | ||||||
|   | |||||||
| @@ -1,10 +1,13 @@ | |||||||
| { | { | ||||||
| 	"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", | 	"$schema": "https://biomejs.dev/schemas/2.3.0/schema.json", | ||||||
| 	"vcs": { | 	"vcs": { | ||||||
| 		"enabled": true, | 		"enabled": true, | ||||||
| 		"clientKind": "git", | 		"clientKind": "git", | ||||||
| 		"useIgnoreFile": true | 		"useIgnoreFile": true | ||||||
| 	}, | 	}, | ||||||
|  | 	"files": { | ||||||
|  | 		"includes": ["**", "!**/*.css"] | ||||||
|  | 	}, | ||||||
| 	"formatter": { | 	"formatter": { | ||||||
| 		"enabled": true | 		"enabled": true | ||||||
| 	}, | 	}, | ||||||
|   | |||||||
| @@ -136,6 +136,24 @@ When you do this, updating to a new version requires manually updating the image | |||||||
| | `PM_DB_CONN_MAX_ATTEMPTS`  | Maximum database connection attempts                 | `30`                                             | | | `PM_DB_CONN_MAX_ATTEMPTS`  | Maximum database connection attempts                 | `30`                                             | | ||||||
| | `PM_DB_CONN_WAIT_INTERVAL` | Wait interval between connection attempts in seconds | `2`                                              | | | `PM_DB_CONN_WAIT_INTERVAL` | Wait interval between connection attempts in seconds | `2`                                              | | ||||||
|  |  | ||||||
|  | ##### Database Connection Pool Configuration (Prisma) | ||||||
|  |  | ||||||
|  | | Variable              | Description                                                | Default | | ||||||
|  | | --------------------- | ---------------------------------------------------------- | ------- | | ||||||
|  | | `DB_CONNECTION_LIMIT` | Maximum number of database connections per instance        | `30`    | | ||||||
|  | | `DB_POOL_TIMEOUT`     | Seconds to wait for an available connection before timeout | `20`    | | ||||||
|  | | `DB_CONNECT_TIMEOUT`  | Seconds to wait for initial database connection            | `10`    | | ||||||
|  | | `DB_IDLE_TIMEOUT`     | Seconds before closing idle connections                    | `300`   | | ||||||
|  | | `DB_MAX_LIFETIME`     | Maximum lifetime of a connection in seconds                | `1800`  | | ||||||
|  |  | ||||||
|  | > [!TIP] | ||||||
|  | > The connection pool limit should be adjusted based on your deployment size: | ||||||
|  | > - **Small deployment (1-10 hosts)**: `DB_CONNECTION_LIMIT=15` is sufficient | ||||||
|  | > - **Medium deployment (10-50 hosts)**: `DB_CONNECTION_LIMIT=30` (default) | ||||||
|  | > - **Large deployment (50+ hosts)**: `DB_CONNECTION_LIMIT=50` or higher | ||||||
|  | >  | ||||||
|  | > Each connection pool serves one backend instance. If you have concurrent operations (multiple users, background jobs, agent checkins), increase the pool size accordingly. | ||||||
|  |  | ||||||
| ##### Redis Configuration | ##### Redis Configuration | ||||||
|  |  | ||||||
| | Variable        | Description                    | Default | | | Variable        | Description                    | Default | | ||||||
|   | |||||||
| @@ -46,8 +46,10 @@ COPY --chown=node:node backend/ ./backend/ | |||||||
|  |  | ||||||
| WORKDIR /app/backend | WORKDIR /app/backend | ||||||
|  |  | ||||||
| RUN npm ci --ignore-scripts &&\ | RUN npm cache clean --force &&\ | ||||||
|     npx prisma generate &&\ |     rm -rf node_modules ~/.npm /root/.npm &&\ | ||||||
|  |     npm ci --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=3 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 &&\ | ||||||
|  |     PRISMA_CLI_BINARY_TYPE=binary npm run db:generate &&\ | ||||||
|     npm prune --omit=dev &&\ |     npm prune --omit=dev &&\ | ||||||
|     npm cache clean --force |     npm cache clean --force | ||||||
|  |  | ||||||
|   | |||||||
| @@ -50,6 +50,19 @@ services: | |||||||
|       SERVER_HOST: localhost |       SERVER_HOST: localhost | ||||||
|       SERVER_PORT: 3000 |       SERVER_PORT: 3000 | ||||||
|       CORS_ORIGIN: http://localhost:3000 |       CORS_ORIGIN: http://localhost:3000 | ||||||
|  |       # Database Connection Pool Configuration (Prisma) | ||||||
|  |       DB_CONNECTION_LIMIT: 30 | ||||||
|  |       DB_POOL_TIMEOUT: 20 | ||||||
|  |       DB_CONNECT_TIMEOUT: 10 | ||||||
|  |       DB_IDLE_TIMEOUT: 300 | ||||||
|  |       DB_MAX_LIFETIME: 1800 | ||||||
|  |       # Rate Limiting (times in milliseconds) | ||||||
|  |       RATE_LIMIT_WINDOW_MS: 900000 | ||||||
|  |       RATE_LIMIT_MAX: 5000 | ||||||
|  |       AUTH_RATE_LIMIT_WINDOW_MS: 600000 | ||||||
|  |       AUTH_RATE_LIMIT_MAX: 500 | ||||||
|  |       AGENT_RATE_LIMIT_WINDOW_MS: 60000 | ||||||
|  |       AGENT_RATE_LIMIT_MAX: 1000 | ||||||
|       # Redis Configuration |       # Redis Configuration | ||||||
|       REDIS_HOST: redis |       REDIS_HOST: redis | ||||||
|       REDIS_PORT: 6379 |       REDIS_PORT: 6379 | ||||||
|   | |||||||
| @@ -56,6 +56,19 @@ services: | |||||||
|       SERVER_HOST: localhost |       SERVER_HOST: localhost | ||||||
|       SERVER_PORT: 3000 |       SERVER_PORT: 3000 | ||||||
|       CORS_ORIGIN: http://localhost:3000 |       CORS_ORIGIN: http://localhost:3000 | ||||||
|  |       # Database Connection Pool Configuration (Prisma) | ||||||
|  |       DB_CONNECTION_LIMIT: 30 | ||||||
|  |       DB_POOL_TIMEOUT: 20 | ||||||
|  |       DB_CONNECT_TIMEOUT: 10 | ||||||
|  |       DB_IDLE_TIMEOUT: 300 | ||||||
|  |       DB_MAX_LIFETIME: 1800 | ||||||
|  |       # Rate Limiting (times in milliseconds) | ||||||
|  |       RATE_LIMIT_WINDOW_MS: 900000 | ||||||
|  |       RATE_LIMIT_MAX: 5000 | ||||||
|  |       AUTH_RATE_LIMIT_WINDOW_MS: 600000 | ||||||
|  |       AUTH_RATE_LIMIT_MAX: 500 | ||||||
|  |       AGENT_RATE_LIMIT_WINDOW_MS: 60000 | ||||||
|  |       AGENT_RATE_LIMIT_MAX: 1000 | ||||||
|       # Redis Configuration |       # Redis Configuration | ||||||
|       REDIS_HOST: redis |       REDIS_HOST: redis | ||||||
|       REDIS_PORT: 6379 |       REDIS_PORT: 6379 | ||||||
|   | |||||||
| @@ -17,16 +17,21 @@ CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"] | |||||||
| # Builder stage for production | # Builder stage for production | ||||||
| FROM node:lts-alpine AS builder | FROM node:lts-alpine AS builder | ||||||
|  |  | ||||||
| WORKDIR /app | WORKDIR /app/frontend | ||||||
|  |  | ||||||
| COPY package*.json ./ | COPY frontend/package*.json ./ | ||||||
| COPY frontend/package*.json ./frontend/ |  | ||||||
|  |  | ||||||
| RUN npm ci --ignore-scripts | RUN echo "=== Starting npm install ===" &&\ | ||||||
|  |     npm cache clean --force &&\ | ||||||
|  |     rm -rf node_modules ~/.npm /root/.npm &&\ | ||||||
|  |     echo "=== npm install ===" &&\ | ||||||
|  |     npm install --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=3 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 &&\ | ||||||
|  |     echo "=== npm install completed ===" &&\ | ||||||
|  |     npm cache clean --force | ||||||
|  |  | ||||||
| COPY frontend/ ./frontend/ | COPY frontend/ ./ | ||||||
|  |  | ||||||
| RUN npm run build:frontend | RUN npm run build | ||||||
|  |  | ||||||
| # Production stage | # Production stage | ||||||
| FROM nginxinc/nginx-unprivileged:alpine | FROM nginxinc/nginx-unprivileged:alpine | ||||||
|   | |||||||
							
								
								
									
										10
									
								
								frontend/env.example
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								frontend/env.example
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | |||||||
|  | # Frontend Environment Configuration | ||||||
|  | # This file is used by Vite during build and runtime | ||||||
|  |  | ||||||
|  | # API URL - Update this to match your backend server | ||||||
|  | VITE_API_URL=http://localhost:3001/api/v1 | ||||||
|  |  | ||||||
|  | # Application Metadata | ||||||
|  | VITE_APP_NAME=PatchMon | ||||||
|  | VITE_APP_VERSION=1.3.1 | ||||||
|  |  | ||||||
| @@ -1,7 +1,7 @@ | |||||||
| { | { | ||||||
| 	"name": "patchmon-frontend", | 	"name": "patchmon-frontend", | ||||||
| 	"private": true, | 	"private": true, | ||||||
| 	"version": "1.3.0", | 	"version": "1.3.1", | ||||||
| 	"license": "AGPL-3.0", | 	"license": "AGPL-3.0", | ||||||
| 	"type": "module", | 	"type": "module", | ||||||
| 	"scripts": { | 	"scripts": { | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ import ProtectedRoute from "./components/ProtectedRoute"; | |||||||
| import SettingsLayout from "./components/SettingsLayout"; | import SettingsLayout from "./components/SettingsLayout"; | ||||||
| import { isAuthPhase } from "./constants/authPhases"; | import { isAuthPhase } from "./constants/authPhases"; | ||||||
| import { AuthProvider, useAuth } from "./contexts/AuthContext"; | import { AuthProvider, useAuth } from "./contexts/AuthContext"; | ||||||
|  | import { ColorThemeProvider } from "./contexts/ColorThemeContext"; | ||||||
| import { ThemeProvider } from "./contexts/ThemeContext"; | import { ThemeProvider } from "./contexts/ThemeContext"; | ||||||
| import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext"; | import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext"; | ||||||
|  |  | ||||||
| @@ -41,6 +42,7 @@ const SettingsServerConfig = lazy( | |||||||
| 	() => import("./pages/settings/SettingsServerConfig"), | 	() => import("./pages/settings/SettingsServerConfig"), | ||||||
| ); | ); | ||||||
| const SettingsUsers = lazy(() => import("./pages/settings/SettingsUsers")); | const SettingsUsers = lazy(() => import("./pages/settings/SettingsUsers")); | ||||||
|  | const SettingsMetrics = lazy(() => import("./pages/settings/SettingsMetrics")); | ||||||
|  |  | ||||||
| // Loading fallback component | // Loading fallback component | ||||||
| const LoadingFallback = () => ( | const LoadingFallback = () => ( | ||||||
| @@ -388,6 +390,16 @@ function AppRoutes() { | |||||||
| 						</ProtectedRoute> | 						</ProtectedRoute> | ||||||
| 					} | 					} | ||||||
| 				/> | 				/> | ||||||
|  | 				<Route | ||||||
|  | 					path="/settings/metrics" | ||||||
|  | 					element={ | ||||||
|  | 						<ProtectedRoute requirePermission="can_manage_settings"> | ||||||
|  | 							<Layout> | ||||||
|  | 								<SettingsMetrics /> | ||||||
|  | 							</Layout> | ||||||
|  | 						</ProtectedRoute> | ||||||
|  | 					} | ||||||
|  | 				/> | ||||||
| 				<Route | 				<Route | ||||||
| 					path="/options" | 					path="/options" | ||||||
| 					element={ | 					element={ | ||||||
| @@ -416,6 +428,7 @@ function AppRoutes() { | |||||||
| function App() { | function App() { | ||||||
| 	return ( | 	return ( | ||||||
| 		<ThemeProvider> | 		<ThemeProvider> | ||||||
|  | 			<ColorThemeProvider> | ||||||
| 				<AuthProvider> | 				<AuthProvider> | ||||||
| 					<UpdateNotificationProvider> | 					<UpdateNotificationProvider> | ||||||
| 						<LogoProvider> | 						<LogoProvider> | ||||||
| @@ -423,6 +436,7 @@ function App() { | |||||||
| 						</LogoProvider> | 						</LogoProvider> | ||||||
| 					</UpdateNotificationProvider> | 					</UpdateNotificationProvider> | ||||||
| 				</AuthProvider> | 				</AuthProvider> | ||||||
|  | 			</ColorThemeProvider> | ||||||
| 		</ThemeProvider> | 		</ThemeProvider> | ||||||
| 	); | 	); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -26,9 +26,10 @@ import { | |||||||
| 	Zap, | 	Zap, | ||||||
| } from "lucide-react"; | } from "lucide-react"; | ||||||
| import { useCallback, useEffect, useRef, useState } from "react"; | import { useCallback, useEffect, useRef, useState } from "react"; | ||||||
| import { FaYoutube } from "react-icons/fa"; | import { FaReddit, FaYoutube } from "react-icons/fa"; | ||||||
| import { Link, useLocation, useNavigate } from "react-router-dom"; | import { Link, useLocation, useNavigate } from "react-router-dom"; | ||||||
| import { useAuth } from "../contexts/AuthContext"; | import { useAuth } from "../contexts/AuthContext"; | ||||||
|  | import { useColorTheme } from "../contexts/ColorThemeContext"; | ||||||
| import { useUpdateNotification } from "../contexts/UpdateNotificationContext"; | import { useUpdateNotification } from "../contexts/UpdateNotificationContext"; | ||||||
| import { dashboardAPI, versionAPI } from "../utils/api"; | import { dashboardAPI, versionAPI } from "../utils/api"; | ||||||
| import DiscordIcon from "./DiscordIcon"; | import DiscordIcon from "./DiscordIcon"; | ||||||
| @@ -61,7 +62,9 @@ const Layout = ({ children }) => { | |||||||
| 		canManageSettings, | 		canManageSettings, | ||||||
| 	} = useAuth(); | 	} = useAuth(); | ||||||
| 	const { updateAvailable } = useUpdateNotification(); | 	const { updateAvailable } = useUpdateNotification(); | ||||||
|  | 	const { themeConfig } = useColorTheme(); | ||||||
| 	const userMenuRef = useRef(null); | 	const userMenuRef = useRef(null); | ||||||
|  | 	const bgCanvasRef = useRef(null); | ||||||
|  |  | ||||||
| 	// Fetch dashboard stats for the "Last updated" info | 	// Fetch dashboard stats for the "Last updated" info | ||||||
| 	const { | 	const { | ||||||
| @@ -233,27 +236,165 @@ const Layout = ({ children }) => { | |||||||
| 		navigate("/hosts?action=add"); | 		navigate("/hosts?action=add"); | ||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
|  | 	// Generate clean radial gradient background with subtle triangular accents for dark mode | ||||||
|  | 	useEffect(() => { | ||||||
|  | 		const generateBackground = () => { | ||||||
|  | 			if ( | ||||||
|  | 				!bgCanvasRef.current || | ||||||
|  | 				!themeConfig?.login || | ||||||
|  | 				!document.documentElement.classList.contains("dark") | ||||||
|  | 			) { | ||||||
|  | 				return; | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			const canvas = bgCanvasRef.current; | ||||||
|  | 			canvas.width = window.innerWidth; | ||||||
|  | 			canvas.height = window.innerHeight; | ||||||
|  | 			const ctx = canvas.getContext("2d"); | ||||||
|  |  | ||||||
|  | 			// Get theme colors - pick first color from each palette | ||||||
|  | 			const xColors = themeConfig.login.xColors || [ | ||||||
|  | 				"#667eea", | ||||||
|  | 				"#764ba2", | ||||||
|  | 				"#f093fb", | ||||||
|  | 				"#4facfe", | ||||||
|  | 			]; | ||||||
|  | 			const yColors = themeConfig.login.yColors || [ | ||||||
|  | 				"#667eea", | ||||||
|  | 				"#764ba2", | ||||||
|  | 				"#f093fb", | ||||||
|  | 				"#4facfe", | ||||||
|  | 			]; | ||||||
|  |  | ||||||
|  | 			// Use date for daily color rotation | ||||||
|  | 			const today = new Date(); | ||||||
|  | 			const seed = | ||||||
|  | 				today.getFullYear() * 10000 + today.getMonth() * 100 + today.getDate(); | ||||||
|  | 			const random = (s) => { | ||||||
|  | 				const x = Math.sin(s) * 10000; | ||||||
|  | 				return x - Math.floor(x); | ||||||
|  | 			}; | ||||||
|  |  | ||||||
|  | 			const color1 = xColors[Math.floor(random(seed) * xColors.length)]; | ||||||
|  | 			const color2 = yColors[Math.floor(random(seed + 1000) * yColors.length)]; | ||||||
|  |  | ||||||
|  | 			// Create clean radial gradient from center to bottom-right corner | ||||||
|  | 			const gradient = ctx.createRadialGradient( | ||||||
|  | 				canvas.width * 0.3, // Center slightly left | ||||||
|  | 				canvas.height * 0.3, // Center slightly up | ||||||
|  | 				0, | ||||||
|  | 				canvas.width * 0.5, // Expand to cover screen | ||||||
|  | 				canvas.height * 0.5, | ||||||
|  | 				Math.max(canvas.width, canvas.height) * 1.2, | ||||||
|  | 			); | ||||||
|  |  | ||||||
|  | 			// Subtle gradient with darker corners | ||||||
|  | 			gradient.addColorStop(0, color1); | ||||||
|  | 			gradient.addColorStop(0.6, color2); | ||||||
|  | 			gradient.addColorStop(1, "#0a0a0a"); // Very dark edges | ||||||
|  |  | ||||||
|  | 			ctx.fillStyle = gradient; | ||||||
|  | 			ctx.fillRect(0, 0, canvas.width, canvas.height); | ||||||
|  |  | ||||||
|  | 			// Add subtle triangular shapes as accents across entire background | ||||||
|  | 			const cellSize = 180; | ||||||
|  | 			const cols = Math.ceil(canvas.width / cellSize) + 1; | ||||||
|  | 			const rows = Math.ceil(canvas.height / cellSize) + 1; | ||||||
|  |  | ||||||
|  | 			for (let y = 0; y < rows; y++) { | ||||||
|  | 				for (let x = 0; x < cols; x++) { | ||||||
|  | 					const idx = y * cols + x; | ||||||
|  | 					// Draw more triangles (less sparse) | ||||||
|  | 					if (random(seed + idx + 5000) > 0.4) { | ||||||
|  | 						const baseX = | ||||||
|  | 							x * cellSize + random(seed + idx * 3) * cellSize * 0.8; | ||||||
|  | 						const baseY = | ||||||
|  | 							y * cellSize + random(seed + idx * 3 + 100) * cellSize * 0.8; | ||||||
|  | 						const size = 50 + random(seed + idx * 4) * 100; | ||||||
|  |  | ||||||
|  | 						ctx.beginPath(); | ||||||
|  | 						ctx.moveTo(baseX, baseY); | ||||||
|  | 						ctx.lineTo(baseX + size, baseY); | ||||||
|  | 						ctx.lineTo(baseX + size / 2, baseY - size * 0.866); | ||||||
|  | 						ctx.closePath(); | ||||||
|  |  | ||||||
|  | 						// More visible white with slightly higher opacity | ||||||
|  | 						ctx.fillStyle = `rgba(255, 255, 255, ${0.05 + random(seed + idx * 5) * 0.08})`; | ||||||
|  | 						ctx.fill(); | ||||||
|  | 					} | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		}; | ||||||
|  |  | ||||||
|  | 		generateBackground(); | ||||||
|  |  | ||||||
|  | 		// Regenerate on window resize or theme change | ||||||
|  | 		const handleResize = () => { | ||||||
|  | 			generateBackground(); | ||||||
|  | 		}; | ||||||
|  |  | ||||||
|  | 		window.addEventListener("resize", handleResize); | ||||||
|  |  | ||||||
|  | 		// Watch for dark mode changes | ||||||
|  | 		const observer = new MutationObserver((mutations) => { | ||||||
|  | 			mutations.forEach((mutation) => { | ||||||
|  | 				if (mutation.attributeName === "class") { | ||||||
|  | 					generateBackground(); | ||||||
|  | 				} | ||||||
|  | 			}); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		observer.observe(document.documentElement, { | ||||||
|  | 			attributes: true, | ||||||
|  | 			attributeFilter: ["class"], | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		return () => { | ||||||
|  | 			window.removeEventListener("resize", handleResize); | ||||||
|  | 			observer.disconnect(); | ||||||
|  | 		}; | ||||||
|  | 	}, [themeConfig]); | ||||||
|  |  | ||||||
| 	// Fetch GitHub stars count | 	// Fetch GitHub stars count | ||||||
| 	const fetchGitHubStars = useCallback(async () => { | 	const fetchGitHubStars = useCallback(async () => { | ||||||
| 		// Skip if already fetched recently | 		// Try to load cached star count first | ||||||
|  | 		const cachedStars = localStorage.getItem("githubStarsCount"); | ||||||
|  | 		if (cachedStars) { | ||||||
|  | 			setGithubStars(parseInt(cachedStars, 10)); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Skip API call if fetched recently | ||||||
| 		const lastFetch = localStorage.getItem("githubStarsFetchTime"); | 		const lastFetch = localStorage.getItem("githubStarsFetchTime"); | ||||||
| 		const now = Date.now(); | 		const now = Date.now(); | ||||||
| 		if (lastFetch && now - parseInt(lastFetch, 15) < 600000) { | 		if (lastFetch && now - parseInt(lastFetch, 10) < 600000) { | ||||||
| 			// 15 minute cache | 			// 10 minute cache | ||||||
| 			return; | 			return; | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		try { | 		try { | ||||||
| 			const response = await fetch( | 			const response = await fetch( | ||||||
| 				"https://api.github.com/repos/9technologygroup/patchmon.net", | 				"https://api.github.com/repos/9technologygroup/patchmon.net", | ||||||
|  | 				{ | ||||||
|  | 					headers: { | ||||||
|  | 						Accept: "application/vnd.github.v3+json", | ||||||
|  | 					}, | ||||||
|  | 				}, | ||||||
| 			); | 			); | ||||||
|  |  | ||||||
| 			if (response.ok) { | 			if (response.ok) { | ||||||
| 				const data = await response.json(); | 				const data = await response.json(); | ||||||
| 				setGithubStars(data.stargazers_count); | 				setGithubStars(data.stargazers_count); | ||||||
|  | 				localStorage.setItem( | ||||||
|  | 					"githubStarsCount", | ||||||
|  | 					data.stargazers_count.toString(), | ||||||
|  | 				); | ||||||
| 				localStorage.setItem("githubStarsFetchTime", now.toString()); | 				localStorage.setItem("githubStarsFetchTime", now.toString()); | ||||||
|  | 			} else if (response.status === 403 || response.status === 429) { | ||||||
|  | 				console.warn("GitHub API rate limit exceeded, using cached value"); | ||||||
| 			} | 			} | ||||||
| 		} catch (error) { | 		} catch (error) { | ||||||
| 			console.error("Failed to fetch GitHub stars:", error); | 			console.error("Failed to fetch GitHub stars:", error); | ||||||
|  | 			// Keep using cached value if available | ||||||
| 		} | 		} | ||||||
| 	}, []); | 	}, []); | ||||||
|  |  | ||||||
| @@ -303,11 +444,76 @@ const Layout = ({ children }) => { | |||||||
| 		fetchGitHubStars(); | 		fetchGitHubStars(); | ||||||
| 	}, [fetchGitHubStars]); | 	}, [fetchGitHubStars]); | ||||||
|  |  | ||||||
|  | 	// Set CSS custom properties for glassmorphism and theme colors in dark mode | ||||||
|  | 	useEffect(() => { | ||||||
|  | 		const updateThemeStyles = () => { | ||||||
|  | 			const isDark = document.documentElement.classList.contains("dark"); | ||||||
|  | 			const root = document.documentElement; | ||||||
|  |  | ||||||
|  | 			if (isDark && themeConfig?.app) { | ||||||
|  | 				// Glass navigation bars - very light for pattern visibility | ||||||
|  | 				root.style.setProperty("--sidebar-bg", "rgba(0, 0, 0, 0.15)"); | ||||||
|  | 				root.style.setProperty("--sidebar-blur", "blur(12px)"); | ||||||
|  | 				root.style.setProperty("--topbar-bg", "rgba(0, 0, 0, 0.15)"); | ||||||
|  | 				root.style.setProperty("--topbar-blur", "blur(12px)"); | ||||||
|  | 				root.style.setProperty("--button-bg", "rgba(255, 255, 255, 0.15)"); | ||||||
|  | 				root.style.setProperty("--button-blur", "blur(8px)"); | ||||||
|  |  | ||||||
|  | 				// Theme-colored cards and buttons - darker to stand out | ||||||
|  | 				root.style.setProperty("--card-bg", themeConfig.app.cardBg); | ||||||
|  | 				root.style.setProperty("--card-border", themeConfig.app.cardBorder); | ||||||
|  | 				root.style.setProperty("--card-bg-hover", themeConfig.app.bgTertiary); | ||||||
|  | 				root.style.setProperty("--theme-button-bg", themeConfig.app.buttonBg); | ||||||
|  | 				root.style.setProperty( | ||||||
|  | 					"--theme-button-hover", | ||||||
|  | 					themeConfig.app.buttonHover, | ||||||
|  | 				); | ||||||
|  | 			} else { | ||||||
|  | 				// Light mode - standard colors | ||||||
|  | 				root.style.setProperty("--sidebar-bg", "white"); | ||||||
|  | 				root.style.setProperty("--sidebar-blur", "none"); | ||||||
|  | 				root.style.setProperty("--topbar-bg", "white"); | ||||||
|  | 				root.style.setProperty("--topbar-blur", "none"); | ||||||
|  | 				root.style.setProperty("--button-bg", "white"); | ||||||
|  | 				root.style.setProperty("--button-blur", "none"); | ||||||
|  | 				root.style.setProperty("--card-bg", "white"); | ||||||
|  | 				root.style.setProperty("--card-border", "#e5e7eb"); | ||||||
|  | 				root.style.setProperty("--card-bg-hover", "#f9fafb"); | ||||||
|  | 				root.style.setProperty("--theme-button-bg", "#f3f4f6"); | ||||||
|  | 				root.style.setProperty("--theme-button-hover", "#e5e7eb"); | ||||||
|  | 			} | ||||||
|  | 		}; | ||||||
|  |  | ||||||
|  | 		updateThemeStyles(); | ||||||
|  |  | ||||||
|  | 		// Watch for dark mode changes | ||||||
|  | 		const observer = new MutationObserver(() => { | ||||||
|  | 			updateThemeStyles(); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		observer.observe(document.documentElement, { | ||||||
|  | 			attributes: true, | ||||||
|  | 			attributeFilter: ["class"], | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		return () => observer.disconnect(); | ||||||
|  | 	}, [themeConfig]); | ||||||
|  |  | ||||||
| 	return ( | 	return ( | ||||||
| 		<div className="min-h-screen bg-secondary-50"> | 		<div className="min-h-screen bg-secondary-50 dark:bg-black relative overflow-hidden"> | ||||||
|  | 			{/* Full-screen Trianglify Background (Dark Mode Only) */} | ||||||
|  | 			<canvas | ||||||
|  | 				ref={bgCanvasRef} | ||||||
|  | 				className="fixed inset-0 w-full h-full hidden dark:block" | ||||||
|  | 				style={{ zIndex: 0 }} | ||||||
|  | 			/> | ||||||
|  | 			<div | ||||||
|  | 				className="fixed inset-0 bg-gradient-to-br from-black/10 to-black/20 hidden dark:block pointer-events-none" | ||||||
|  | 				style={{ zIndex: 1 }} | ||||||
|  | 			/> | ||||||
| 			{/* Mobile sidebar */} | 			{/* Mobile sidebar */} | ||||||
| 			<div | 			<div | ||||||
| 				className={`fixed inset-0 z-50 lg:hidden ${sidebarOpen ? "block" : "hidden"}`} | 				className={`fixed inset-0 z-[60] lg:hidden ${sidebarOpen ? "block" : "hidden"}`} | ||||||
| 			> | 			> | ||||||
| 				<button | 				<button | ||||||
| 					type="button" | 					type="button" | ||||||
| @@ -315,7 +521,14 @@ const Layout = ({ children }) => { | |||||||
| 					onClick={() => setSidebarOpen(false)} | 					onClick={() => setSidebarOpen(false)} | ||||||
| 					aria-label="Close sidebar" | 					aria-label="Close sidebar" | ||||||
| 				/> | 				/> | ||||||
| 				<div className="relative flex w-full max-w-[280px] flex-col bg-white dark:bg-secondary-800 pb-4 pt-5 shadow-xl"> | 				<div | ||||||
|  | 					className="relative flex w-full max-w-[280px] flex-col bg-white dark:border-r dark:border-white/10 pb-4 pt-5 shadow-xl" | ||||||
|  | 					style={{ | ||||||
|  | 						backgroundColor: "var(--sidebar-bg, white)", | ||||||
|  | 						backdropFilter: "var(--sidebar-blur, none)", | ||||||
|  | 						WebkitBackdropFilter: "var(--sidebar-blur, none)", | ||||||
|  | 					}} | ||||||
|  | 				> | ||||||
| 					<div className="absolute right-0 top-0 -mr-12 pt-2"> | 					<div className="absolute right-0 top-0 -mr-12 pt-2"> | ||||||
| 						<button | 						<button | ||||||
| 							type="button" | 							type="button" | ||||||
| @@ -534,17 +747,43 @@ const Layout = ({ children }) => { | |||||||
|  |  | ||||||
| 			{/* Desktop sidebar */} | 			{/* Desktop sidebar */} | ||||||
| 			<div | 			<div | ||||||
| 				className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 relative ${ | 				className={`hidden lg:fixed lg:inset-y-0 z-[100] lg:flex lg:flex-col transition-all duration-300 relative ${ | ||||||
| 					sidebarCollapsed ? "lg:w-16" : "lg:w-56" | 					sidebarCollapsed ? "lg:w-16" : "lg:w-56" | ||||||
| 				} bg-white dark:bg-secondary-800`} | 				} bg-white dark:bg-transparent`} | ||||||
| 			> | 			> | ||||||
|  | 				{/* Collapse/Expand button on border */} | ||||||
|  | 				<button | ||||||
|  | 					type="button" | ||||||
|  | 					onClick={() => setSidebarCollapsed(!sidebarCollapsed)} | ||||||
|  | 					className="absolute top-5 -right-3 z-[200] flex items-center justify-center w-6 h-6 rounded-full bg-white border border-secondary-300 dark:border-white/20 shadow-md hover:bg-secondary-50 transition-colors" | ||||||
|  | 					style={{ | ||||||
|  | 						backgroundColor: "var(--button-bg, white)", | ||||||
|  | 						backdropFilter: "var(--button-blur, none)", | ||||||
|  | 						WebkitBackdropFilter: "var(--button-blur, none)", | ||||||
|  | 					}} | ||||||
|  | 					title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} | ||||||
|  | 				> | ||||||
|  | 					{sidebarCollapsed ? ( | ||||||
|  | 						<ChevronRight className="h-4 w-4 text-secondary-700 dark:text-white" /> | ||||||
|  | 					) : ( | ||||||
|  | 						<ChevronLeft className="h-4 w-4 text-secondary-700 dark:text-white" /> | ||||||
|  | 					)} | ||||||
|  | 				</button> | ||||||
|  |  | ||||||
| 				<div | 				<div | ||||||
| 					className={`flex grow flex-col gap-y-5 overflow-y-auto border-r border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 ${ | 					className={`flex grow flex-col gap-y-5 border-r border-secondary-200 dark:border-white/10 bg-white ${ | ||||||
| 						sidebarCollapsed ? "px-2 shadow-lg" : "px-6" | 						sidebarCollapsed ? "px-2 shadow-lg" : "px-6" | ||||||
| 					}`} | 					}`} | ||||||
|  | 					style={{ | ||||||
|  | 						backgroundColor: "var(--sidebar-bg, white)", | ||||||
|  | 						backdropFilter: "var(--sidebar-blur, none)", | ||||||
|  | 						WebkitBackdropFilter: "var(--sidebar-blur, none)", | ||||||
|  | 						overflowY: "auto", | ||||||
|  | 						overflowX: "visible", | ||||||
|  | 					}} | ||||||
| 				> | 				> | ||||||
| 					<div | 					<div | ||||||
| 						className={`flex h-16 shrink-0 items-center border-b border-secondary-200 dark:border-secondary-600 ${ | 						className={`flex h-16 shrink-0 items-center border-b border-secondary-200 dark:border-white/10 ${ | ||||||
| 							sidebarCollapsed ? "justify-center" : "justify-center" | 							sidebarCollapsed ? "justify-center" : "justify-center" | ||||||
| 						}`} | 						}`} | ||||||
| 					> | 					> | ||||||
| @@ -562,19 +801,6 @@ const Layout = ({ children }) => { | |||||||
| 							</Link> | 							</Link> | ||||||
| 						)} | 						)} | ||||||
| 					</div> | 					</div> | ||||||
| 					{/* Collapse/Expand button on border */} |  | ||||||
| 					<button |  | ||||||
| 						type="button" |  | ||||||
| 						onClick={() => setSidebarCollapsed(!sidebarCollapsed)} |  | ||||||
| 						className="absolute top-5 -right-3 z-10 flex items-center justify-center w-6 h-6 rounded-full bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 shadow-md hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors" |  | ||||||
| 						title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} |  | ||||||
| 					> |  | ||||||
| 						{sidebarCollapsed ? ( |  | ||||||
| 							<ChevronRight className="h-4 w-4 text-secondary-700 dark:text-white" /> |  | ||||||
| 						) : ( |  | ||||||
| 							<ChevronLeft className="h-4 w-4 text-secondary-700 dark:text-white" /> |  | ||||||
| 						)} |  | ||||||
| 					</button> |  | ||||||
| 					<nav className="flex flex-1 flex-col"> | 					<nav className="flex flex-1 flex-col"> | ||||||
| 						<ul className="flex flex-1 flex-col gap-y-6"> | 						<ul className="flex flex-1 flex-col gap-y-6"> | ||||||
| 							{/* Show message for users with very limited permissions */} | 							{/* Show message for users with very limited permissions */} | ||||||
| @@ -930,12 +1156,19 @@ const Layout = ({ children }) => { | |||||||
|  |  | ||||||
| 			{/* Main content */} | 			{/* Main content */} | ||||||
| 			<div | 			<div | ||||||
| 				className={`flex flex-col min-h-screen transition-all duration-300 ${ | 				className={`flex flex-col min-h-screen transition-all duration-300 relative z-10 ${ | ||||||
| 					sidebarCollapsed ? "lg:pl-16" : "lg:pl-56" | 					sidebarCollapsed ? "lg:pl-16" : "lg:pl-56" | ||||||
| 				}`} | 				}`} | ||||||
| 			> | 			> | ||||||
| 				{/* Top bar */} | 				{/* Top bar */} | ||||||
| 				<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8"> | 				<div | ||||||
|  | 					className="sticky top-0 z-[90] flex h-16 shrink-0 items-center gap-x-4 border-b border-secondary-200 dark:border-white/10 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8" | ||||||
|  | 					style={{ | ||||||
|  | 						backgroundColor: "var(--topbar-bg, white)", | ||||||
|  | 						backdropFilter: "var(--topbar-blur, none)", | ||||||
|  | 						WebkitBackdropFilter: "var(--topbar-blur, none)", | ||||||
|  | 					}} | ||||||
|  | 				> | ||||||
| 					<button | 					<button | ||||||
| 						type="button" | 						type="button" | ||||||
| 						className="-m-2.5 p-2.5 text-secondary-700 dark:text-white lg:hidden" | 						className="-m-2.5 p-2.5 text-secondary-700 dark:text-white lg:hidden" | ||||||
| @@ -987,8 +1220,8 @@ const Layout = ({ children }) => { | |||||||
| 								> | 								> | ||||||
| 									<Github className="h-5 w-5 flex-shrink-0" /> | 									<Github className="h-5 w-5 flex-shrink-0" /> | ||||||
| 									{githubStars !== null && ( | 									{githubStars !== null && ( | ||||||
| 										<div className="flex items-center gap-0.5"> | 										<div className="flex items-center gap-1"> | ||||||
| 											<Star className="h-3 w-3 fill-current text-yellow-500" /> | 											<Star className="h-4 w-4 fill-current text-yellow-500" /> | ||||||
| 											<span className="text-sm font-medium">{githubStars}</span> | 											<span className="text-sm font-medium">{githubStars}</span> | ||||||
| 										</div> | 										</div> | ||||||
| 									)} | 									)} | ||||||
| @@ -1059,7 +1292,17 @@ const Layout = ({ children }) => { | |||||||
| 								> | 								> | ||||||
| 									<FaYoutube className="h-5 w-5" /> | 									<FaYoutube className="h-5 w-5" /> | ||||||
| 								</a> | 								</a> | ||||||
| 								{/* 7) Web */} | 								{/* 8) Reddit */} | ||||||
|  | 								<a | ||||||
|  | 									href="https://www.reddit.com/r/patchmon" | ||||||
|  | 									target="_blank" | ||||||
|  | 									rel="noopener noreferrer" | ||||||
|  | 									className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm" | ||||||
|  | 									title="Reddit Community" | ||||||
|  | 								> | ||||||
|  | 									<FaReddit className="h-5 w-5" /> | ||||||
|  | 								</a> | ||||||
|  | 								{/* 9) Web */} | ||||||
| 								<a | 								<a | ||||||
| 									href="https://patchmon.net" | 									href="https://patchmon.net" | ||||||
| 									target="_blank" | 									target="_blank" | ||||||
| @@ -1074,7 +1317,7 @@ const Layout = ({ children }) => { | |||||||
| 					</div> | 					</div> | ||||||
| 				</div> | 				</div> | ||||||
|  |  | ||||||
| 				<main className="flex-1 py-6 bg-secondary-50 dark:bg-secondary-800"> | 				<main className="flex-1 py-6 bg-secondary-50 dark:bg-transparent"> | ||||||
| 					<div className="px-4 sm:px-6 lg:px-8">{children}</div> | 					<div className="px-4 sm:px-6 lg:px-8">{children}</div> | ||||||
| 				</main> | 				</main> | ||||||
| 			</div> | 			</div> | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| import { | import { | ||||||
|  | 	BarChart3, | ||||||
| 	Bell, | 	Bell, | ||||||
| 	ChevronLeft, | 	ChevronLeft, | ||||||
| 	ChevronRight, | 	ChevronRight, | ||||||
| @@ -141,6 +142,11 @@ const SettingsLayout = ({ children }) => { | |||||||
| 						href: "/settings/server-version", | 						href: "/settings/server-version", | ||||||
| 						icon: Code, | 						icon: Code, | ||||||
| 					}, | 					}, | ||||||
|  | 					{ | ||||||
|  | 						name: "Metrics", | ||||||
|  | 						href: "/settings/metrics", | ||||||
|  | 						icon: BarChart3, | ||||||
|  | 					}, | ||||||
| 				], | 				], | ||||||
| 			}); | 			}); | ||||||
| 		} | 		} | ||||||
|   | |||||||
| @@ -1,6 +1,14 @@ | |||||||
| import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | ||||||
| import { AlertCircle, Image, RotateCcw, Upload, X } from "lucide-react"; | import { | ||||||
|  | 	AlertCircle, | ||||||
|  | 	Image, | ||||||
|  | 	Palette, | ||||||
|  | 	RotateCcw, | ||||||
|  | 	Upload, | ||||||
|  | 	X, | ||||||
|  | } from "lucide-react"; | ||||||
| import { useState } from "react"; | import { useState } from "react"; | ||||||
|  | import { THEME_PRESETS, useColorTheme } from "../../contexts/ColorThemeContext"; | ||||||
| import { settingsAPI } from "../../utils/api"; | import { settingsAPI } from "../../utils/api"; | ||||||
|  |  | ||||||
| const BrandingTab = () => { | const BrandingTab = () => { | ||||||
| @@ -12,6 +20,7 @@ const BrandingTab = () => { | |||||||
| 	}); | 	}); | ||||||
| 	const [showLogoUploadModal, setShowLogoUploadModal] = useState(false); | 	const [showLogoUploadModal, setShowLogoUploadModal] = useState(false); | ||||||
| 	const [selectedLogoType, setSelectedLogoType] = useState("dark"); | 	const [selectedLogoType, setSelectedLogoType] = useState("dark"); | ||||||
|  | 	const { colorTheme, setColorTheme } = useColorTheme(); | ||||||
|  |  | ||||||
| 	const queryClient = useQueryClient(); | 	const queryClient = useQueryClient(); | ||||||
|  |  | ||||||
| @@ -75,6 +84,22 @@ const BrandingTab = () => { | |||||||
| 		}, | 		}, | ||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
|  | 	// Theme update mutation | ||||||
|  | 	const updateThemeMutation = useMutation({ | ||||||
|  | 		mutationFn: (theme) => settingsAPI.update({ colorTheme: theme }), | ||||||
|  | 		onSuccess: (_data, theme) => { | ||||||
|  | 			queryClient.invalidateQueries(["settings"]); | ||||||
|  | 			setColorTheme(theme); | ||||||
|  | 		}, | ||||||
|  | 		onError: (error) => { | ||||||
|  | 			console.error("Update theme error:", error); | ||||||
|  | 		}, | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	const handleThemeChange = (theme) => { | ||||||
|  | 		updateThemeMutation.mutate(theme); | ||||||
|  | 	}; | ||||||
|  |  | ||||||
| 	if (isLoading) { | 	if (isLoading) { | ||||||
| 		return ( | 		return ( | ||||||
| 			<div className="flex items-center justify-center h-64"> | 			<div className="flex items-center justify-center h-64"> | ||||||
| @@ -102,7 +127,9 @@ const BrandingTab = () => { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	return ( | 	return ( | ||||||
| 		<div className="space-y-6"> | 		<div className="space-y-8"> | ||||||
|  | 			{/* Header */} | ||||||
|  | 			<div> | ||||||
| 				<div className="flex items-center mb-6"> | 				<div className="flex items-center mb-6"> | ||||||
| 					<Image className="h-6 w-6 text-primary-600 mr-3" /> | 					<Image className="h-6 w-6 text-primary-600 mr-3" /> | ||||||
| 					<h2 className="text-xl font-semibold text-secondary-900 dark:text-white"> | 					<h2 className="text-xl font-semibold text-secondary-900 dark:text-white"> | ||||||
| @@ -110,9 +137,100 @@ const BrandingTab = () => { | |||||||
| 					</h2> | 					</h2> | ||||||
| 				</div> | 				</div> | ||||||
| 				<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6"> | 				<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6"> | ||||||
| 				Customize your PatchMon installation with custom logos and favicon. | 					Customize your PatchMon installation with custom logos, favicon, and | ||||||
| 				These will be displayed throughout the application. | 					color themes. These will be displayed throughout the application. | ||||||
| 				</p> | 				</p> | ||||||
|  | 			</div> | ||||||
|  |  | ||||||
|  | 			{/* Color Theme Selector */} | ||||||
|  | 			<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600"> | ||||||
|  | 				<div className="flex items-center mb-4"> | ||||||
|  | 					<Palette className="h-5 w-5 text-primary-600 mr-2" /> | ||||||
|  | 					<h3 className="text-lg font-medium text-secondary-900 dark:text-white"> | ||||||
|  | 						Color Theme | ||||||
|  | 					</h3> | ||||||
|  | 				</div> | ||||||
|  | 				<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6"> | ||||||
|  | 					Choose a color theme that will be applied to the login page and | ||||||
|  | 					background areas throughout the app. | ||||||
|  | 				</p> | ||||||
|  |  | ||||||
|  | 				<div className="grid grid-cols-2 md:grid-cols-3 gap-4"> | ||||||
|  | 					{Object.entries(THEME_PRESETS).map(([themeKey, theme]) => { | ||||||
|  | 						const isSelected = colorTheme === themeKey; | ||||||
|  | 						const gradientColors = theme.login.xColors; | ||||||
|  |  | ||||||
|  | 						return ( | ||||||
|  | 							<button | ||||||
|  | 								key={themeKey} | ||||||
|  | 								type="button" | ||||||
|  | 								onClick={() => handleThemeChange(themeKey)} | ||||||
|  | 								disabled={updateThemeMutation.isPending} | ||||||
|  | 								className={`relative p-4 rounded-lg border-2 transition-all ${ | ||||||
|  | 									isSelected | ||||||
|  | 										? "border-primary-500 ring-2 ring-primary-200 dark:ring-primary-800" | ||||||
|  | 										: "border-secondary-200 dark:border-secondary-600 hover:border-primary-300" | ||||||
|  | 								} ${updateThemeMutation.isPending ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`} | ||||||
|  | 							> | ||||||
|  | 								{/* Theme Preview */} | ||||||
|  | 								<div | ||||||
|  | 									className="h-20 rounded-md mb-3 overflow-hidden" | ||||||
|  | 									style={{ | ||||||
|  | 										background: `linear-gradient(135deg, ${gradientColors.join(", ")})`, | ||||||
|  | 									}} | ||||||
|  | 								/> | ||||||
|  |  | ||||||
|  | 								{/* Theme Name */} | ||||||
|  | 								<div className="text-sm font-medium text-secondary-900 dark:text-white mb-1"> | ||||||
|  | 									{theme.name} | ||||||
|  | 								</div> | ||||||
|  |  | ||||||
|  | 								{/* Selected Indicator */} | ||||||
|  | 								{isSelected && ( | ||||||
|  | 									<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1"> | ||||||
|  | 										<svg | ||||||
|  | 											className="w-4 h-4" | ||||||
|  | 											fill="currentColor" | ||||||
|  | 											viewBox="0 0 20 20" | ||||||
|  | 											aria-label="Selected theme" | ||||||
|  | 										> | ||||||
|  | 											<title>Selected</title> | ||||||
|  | 											<path | ||||||
|  | 												fillRule="evenodd" | ||||||
|  | 												d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" | ||||||
|  | 												clipRule="evenodd" | ||||||
|  | 											/> | ||||||
|  | 										</svg> | ||||||
|  | 									</div> | ||||||
|  | 								)} | ||||||
|  | 							</button> | ||||||
|  | 						); | ||||||
|  | 					})} | ||||||
|  | 				</div> | ||||||
|  |  | ||||||
|  | 				{updateThemeMutation.isPending && ( | ||||||
|  | 					<div className="mt-4 flex items-center gap-2 text-sm text-primary-600 dark:text-primary-400"> | ||||||
|  | 						<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div> | ||||||
|  | 						Updating theme... | ||||||
|  | 					</div> | ||||||
|  | 				)} | ||||||
|  |  | ||||||
|  | 				{updateThemeMutation.isError && ( | ||||||
|  | 					<div className="mt-4 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"> | ||||||
|  | 							Failed to update theme: {updateThemeMutation.error?.message} | ||||||
|  | 						</p> | ||||||
|  | 					</div> | ||||||
|  | 				)} | ||||||
|  | 			</div> | ||||||
|  |  | ||||||
|  | 			{/* Logo Section Header */} | ||||||
|  | 			<div className="flex items-center mb-4"> | ||||||
|  | 				<Image className="h-5 w-5 text-primary-600 mr-2" /> | ||||||
|  | 				<h3 className="text-lg font-medium text-secondary-900 dark:text-white"> | ||||||
|  | 					Logos | ||||||
|  | 				</h3> | ||||||
|  | 			</div> | ||||||
|  |  | ||||||
| 			<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> | 			<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> | ||||||
| 				{/* Dark Logo */} | 				{/* Dark Logo */} | ||||||
|   | |||||||
| @@ -54,7 +54,7 @@ const UsersTab = () => { | |||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
| 	// Update user mutation | 	// Update user mutation | ||||||
| 	const _updateUserMutation = useMutation({ | 	const updateUserMutation = useMutation({ | ||||||
| 		mutationFn: ({ id, data }) => adminUsersAPI.update(id, data), | 		mutationFn: ({ id, data }) => adminUsersAPI.update(id, data), | ||||||
| 		onSuccess: () => { | 		onSuccess: () => { | ||||||
| 			queryClient.invalidateQueries(["users"]); | 			queryClient.invalidateQueries(["users"]); | ||||||
|   | |||||||
							
								
								
									
										194
									
								
								frontend/src/contexts/ColorThemeContext.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										194
									
								
								frontend/src/contexts/ColorThemeContext.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,194 @@ | |||||||
|  | import { createContext, useContext, useEffect, useState } from "react"; | ||||||
|  |  | ||||||
|  | const ColorThemeContext = createContext(); | ||||||
|  |  | ||||||
|  | // Theme configurations matching the login backgrounds | ||||||
|  | export const THEME_PRESETS = { | ||||||
|  | 	default: { | ||||||
|  | 		name: "Normal Dark", | ||||||
|  | 		login: { | ||||||
|  | 			cellSize: 90, | ||||||
|  | 			variance: 0.85, | ||||||
|  | 			xColors: ["#0f172a", "#1e293b", "#334155", "#475569", "#64748b"], | ||||||
|  | 			yColors: ["#0f172a", "#1e293b", "#334155", "#475569", "#64748b"], | ||||||
|  | 		}, | ||||||
|  | 		app: { | ||||||
|  | 			bgPrimary: "#1e293b", | ||||||
|  | 			bgSecondary: "#1e293b", | ||||||
|  | 			bgTertiary: "#334155", | ||||||
|  | 			borderColor: "#475569", | ||||||
|  | 			cardBg: "#1e293b", | ||||||
|  | 			cardBorder: "#334155", | ||||||
|  | 			buttonBg: "#334155", | ||||||
|  | 			buttonHover: "#475569", | ||||||
|  | 		}, | ||||||
|  | 	}, | ||||||
|  | 	cyber_blue: { | ||||||
|  | 		name: "Cyber Blue", | ||||||
|  | 		login: { | ||||||
|  | 			cellSize: 90, | ||||||
|  | 			variance: 0.85, | ||||||
|  | 			xColors: ["#0a0820", "#1a1f3a", "#2d3561", "#4a5584", "#667eaf"], | ||||||
|  | 			yColors: ["#0a0820", "#1a1f3a", "#2d3561", "#4a5584", "#667eaf"], | ||||||
|  | 		}, | ||||||
|  | 		app: { | ||||||
|  | 			bgPrimary: "#0a0820", | ||||||
|  | 			bgSecondary: "#1a1f3a", | ||||||
|  | 			bgTertiary: "#2d3561", | ||||||
|  | 			borderColor: "#4a5584", | ||||||
|  | 			cardBg: "#1a1f3a", | ||||||
|  | 			cardBorder: "#2d3561", | ||||||
|  | 			buttonBg: "#2d3561", | ||||||
|  | 			buttonHover: "#4a5584", | ||||||
|  | 		}, | ||||||
|  | 	}, | ||||||
|  | 	neon_purple: { | ||||||
|  | 		name: "Neon Purple", | ||||||
|  | 		login: { | ||||||
|  | 			cellSize: 80, | ||||||
|  | 			variance: 0.9, | ||||||
|  | 			xColors: ["#0f0a1e", "#1e0f3e", "#4a0082", "#7209b7", "#b5179e"], | ||||||
|  | 			yColors: ["#0f0a1e", "#1e0f3e", "#4a0082", "#7209b7", "#b5179e"], | ||||||
|  | 		}, | ||||||
|  | 		app: { | ||||||
|  | 			bgPrimary: "#0f0a1e", | ||||||
|  | 			bgSecondary: "#1e0f3e", | ||||||
|  | 			bgTertiary: "#4a0082", | ||||||
|  | 			borderColor: "#7209b7", | ||||||
|  | 			cardBg: "#1e0f3e", | ||||||
|  | 			cardBorder: "#4a0082", | ||||||
|  | 			buttonBg: "#4a0082", | ||||||
|  | 			buttonHover: "#7209b7", | ||||||
|  | 		}, | ||||||
|  | 	}, | ||||||
|  | 	matrix_green: { | ||||||
|  | 		name: "Matrix Green", | ||||||
|  | 		login: { | ||||||
|  | 			cellSize: 70, | ||||||
|  | 			variance: 0.7, | ||||||
|  | 			xColors: ["#001a00", "#003300", "#004d00", "#006600", "#00b300"], | ||||||
|  | 			yColors: ["#001a00", "#003300", "#004d00", "#006600", "#00b300"], | ||||||
|  | 		}, | ||||||
|  | 		app: { | ||||||
|  | 			bgPrimary: "#001a00", | ||||||
|  | 			bgSecondary: "#003300", | ||||||
|  | 			bgTertiary: "#004d00", | ||||||
|  | 			borderColor: "#006600", | ||||||
|  | 			cardBg: "#003300", | ||||||
|  | 			cardBorder: "#004d00", | ||||||
|  | 			buttonBg: "#004d00", | ||||||
|  | 			buttonHover: "#006600", | ||||||
|  | 		}, | ||||||
|  | 	}, | ||||||
|  | 	ocean_blue: { | ||||||
|  | 		name: "Ocean Blue", | ||||||
|  | 		login: { | ||||||
|  | 			cellSize: 85, | ||||||
|  | 			variance: 0.8, | ||||||
|  | 			xColors: ["#001845", "#023e7d", "#0077b6", "#0096c7", "#00b4d8"], | ||||||
|  | 			yColors: ["#001845", "#023e7d", "#0077b6", "#0096c7", "#00b4d8"], | ||||||
|  | 		}, | ||||||
|  | 		app: { | ||||||
|  | 			bgPrimary: "#001845", | ||||||
|  | 			bgSecondary: "#023e7d", | ||||||
|  | 			bgTertiary: "#0077b6", | ||||||
|  | 			borderColor: "#0096c7", | ||||||
|  | 			cardBg: "#023e7d", | ||||||
|  | 			cardBorder: "#0077b6", | ||||||
|  | 			buttonBg: "#0077b6", | ||||||
|  | 			buttonHover: "#0096c7", | ||||||
|  | 		}, | ||||||
|  | 	}, | ||||||
|  | 	sunset_gradient: { | ||||||
|  | 		name: "Sunset Gradient", | ||||||
|  | 		login: { | ||||||
|  | 			cellSize: 95, | ||||||
|  | 			variance: 0.75, | ||||||
|  | 			xColors: ["#1a0033", "#330066", "#4d0099", "#6600cc", "#9933ff"], | ||||||
|  | 			yColors: ["#1a0033", "#660033", "#990033", "#cc0066", "#ff0099"], | ||||||
|  | 		}, | ||||||
|  | 		app: { | ||||||
|  | 			bgPrimary: "#1a0033", | ||||||
|  | 			bgSecondary: "#330066", | ||||||
|  | 			bgTertiary: "#4d0099", | ||||||
|  | 			borderColor: "#6600cc", | ||||||
|  | 			cardBg: "#330066", | ||||||
|  | 			cardBorder: "#4d0099", | ||||||
|  | 			buttonBg: "#4d0099", | ||||||
|  | 			buttonHover: "#6600cc", | ||||||
|  | 		}, | ||||||
|  | 	}, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const ColorThemeProvider = ({ children }) => { | ||||||
|  | 	const [colorTheme, setColorTheme] = useState("default"); | ||||||
|  | 	const [isLoading, setIsLoading] = useState(true); | ||||||
|  |  | ||||||
|  | 	// Fetch theme from settings on mount | ||||||
|  | 	useEffect(() => { | ||||||
|  | 		const fetchTheme = async () => { | ||||||
|  | 			try { | ||||||
|  | 				// Check localStorage first for unauthenticated pages (login) | ||||||
|  | 				const cachedTheme = localStorage.getItem("colorTheme"); | ||||||
|  | 				if (cachedTheme) { | ||||||
|  | 					setColorTheme(cachedTheme); | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				// Try to fetch from API (will fail on login page, that's ok) | ||||||
|  | 				try { | ||||||
|  | 					const token = localStorage.getItem("token"); | ||||||
|  | 					if (token) { | ||||||
|  | 						const response = await fetch("/api/v1/settings", { | ||||||
|  | 							headers: { | ||||||
|  | 								Authorization: `Bearer ${token}`, | ||||||
|  | 							}, | ||||||
|  | 						}); | ||||||
|  |  | ||||||
|  | 						if (response.ok) { | ||||||
|  | 							const data = await response.json(); | ||||||
|  | 							if (data.color_theme) { | ||||||
|  | 								setColorTheme(data.color_theme); | ||||||
|  | 								localStorage.setItem("colorTheme", data.color_theme); | ||||||
|  | 							} | ||||||
|  | 						} | ||||||
|  | 					} | ||||||
|  | 				} catch (_apiError) { | ||||||
|  | 					// Silent fail - use cached or default theme | ||||||
|  | 					console.log("Could not fetch theme from API, using cached/default"); | ||||||
|  | 				} | ||||||
|  | 			} catch (error) { | ||||||
|  | 				console.error("Error loading color theme:", error); | ||||||
|  | 			} finally { | ||||||
|  | 				setIsLoading(false); | ||||||
|  | 			} | ||||||
|  | 		}; | ||||||
|  |  | ||||||
|  | 		fetchTheme(); | ||||||
|  | 	}, []); | ||||||
|  |  | ||||||
|  | 	const updateColorTheme = (theme) => { | ||||||
|  | 		setColorTheme(theme); | ||||||
|  | 		localStorage.setItem("colorTheme", theme); | ||||||
|  | 	}; | ||||||
|  |  | ||||||
|  | 	const value = { | ||||||
|  | 		colorTheme, | ||||||
|  | 		setColorTheme: updateColorTheme, | ||||||
|  | 		themeConfig: THEME_PRESETS[colorTheme] || THEME_PRESETS.default, | ||||||
|  | 		isLoading, | ||||||
|  | 	}; | ||||||
|  |  | ||||||
|  | 	return ( | ||||||
|  | 		<ColorThemeContext.Provider value={value}> | ||||||
|  | 			{children} | ||||||
|  | 		</ColorThemeContext.Provider> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const useColorTheme = () => { | ||||||
|  | 	const context = useContext(ColorThemeContext); | ||||||
|  | 	if (!context) { | ||||||
|  | 		throw new Error("useColorTheme must be used within ColorThemeProvider"); | ||||||
|  | 	} | ||||||
|  | 	return context; | ||||||
|  | }; | ||||||
| @@ -9,7 +9,7 @@ | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	body { | 	body { | ||||||
| 		@apply bg-secondary-50 dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 antialiased; | 		@apply bg-secondary-50 dark:bg-transparent text-secondary-900 dark:text-secondary-100 antialiased; | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -39,19 +39,46 @@ | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	.btn-outline { | 	.btn-outline { | ||||||
| 		@apply btn border-secondary-300 dark:border-secondary-600 text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-800 hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-secondary-500; | 		@apply btn border-secondary-300 text-secondary-700 bg-white hover:bg-secondary-50 focus:ring-secondary-500; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	.dark .btn-outline { | ||||||
|  | 		background-color: var(--theme-button-bg, #1e293b); | ||||||
|  | 		border-color: var(--card-border, #334155); | ||||||
|  | 		color: white; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	.dark .btn-outline:hover { | ||||||
|  | 		background-color: var(--theme-button-hover, #334155); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	.card { | 	.card { | ||||||
| 		@apply bg-white dark:bg-secondary-800 rounded-lg shadow-card dark:shadow-card-dark border border-secondary-200 dark:border-secondary-600; | 		@apply bg-white rounded-lg shadow-card border border-secondary-200; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	.dark .card { | ||||||
|  | 		background-color: var(--card-bg, #1e293b); | ||||||
|  | 		border-color: var(--card-border, #334155); | ||||||
|  | 		box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	.card-hover { | 	.card-hover { | ||||||
| 		@apply card hover:shadow-card-hover transition-shadow duration-150; | 		@apply card transition-all duration-150; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	.dark .card-hover:hover { | ||||||
|  | 		background-color: var(--card-bg-hover, #334155); | ||||||
|  | 		box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	.input { | 	.input { | ||||||
| 		@apply block w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100; | 		@apply block w-full px-3 py-2 border border-secondary-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white text-secondary-900; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	.dark .input { | ||||||
|  | 		background-color: var(--card-bg, #1e293b); | ||||||
|  | 		border-color: var(--card-border, #334155); | ||||||
|  | 		color: white; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	.label { | 	.label { | ||||||
| @@ -84,6 +111,27 @@ | |||||||
| } | } | ||||||
|  |  | ||||||
| @layer utilities { | @layer utilities { | ||||||
|  | 	/* Theme-aware backgrounds for general elements */ | ||||||
|  | 	.dark .bg-secondary-800 { | ||||||
|  | 		background-color: var(--card-bg, #1e293b) !important; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	.dark .bg-secondary-700 { | ||||||
|  | 		background-color: var(--card-bg-hover, #334155) !important; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	.dark .bg-secondary-900 { | ||||||
|  | 		background-color: var(--theme-button-bg, #1e293b) !important; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	.dark .border-secondary-600 { | ||||||
|  | 		border-color: var(--card-border, #334155) !important; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	.dark .border-secondary-700 { | ||||||
|  | 		border-color: var(--theme-button-hover, #475569) !important; | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	.text-shadow { | 	.text-shadow { | ||||||
| 		text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); | 		text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -169,6 +169,20 @@ const Automation = () => { | |||||||
| 				year: "numeric", | 				year: "numeric", | ||||||
| 			}); | 			}); | ||||||
| 		} | 		} | ||||||
|  | 		if (schedule === "Daily at 4 AM") { | ||||||
|  | 			const now = new Date(); | ||||||
|  | 			const tomorrow = new Date(now); | ||||||
|  | 			tomorrow.setDate(tomorrow.getDate() + 1); | ||||||
|  | 			tomorrow.setHours(4, 0, 0, 0); | ||||||
|  | 			return tomorrow.toLocaleString([], { | ||||||
|  | 				hour12: true, | ||||||
|  | 				hour: "numeric", | ||||||
|  | 				minute: "2-digit", | ||||||
|  | 				day: "numeric", | ||||||
|  | 				month: "numeric", | ||||||
|  | 				year: "numeric", | ||||||
|  | 			}); | ||||||
|  | 		} | ||||||
| 		if (schedule === "Every hour") { | 		if (schedule === "Every hour") { | ||||||
| 			const now = new Date(); | 			const now = new Date(); | ||||||
| 			const nextHour = new Date(now); | 			const nextHour = new Date(now); | ||||||
| @@ -209,6 +223,13 @@ const Automation = () => { | |||||||
| 			tomorrow.setHours(3, 0, 0, 0); | 			tomorrow.setHours(3, 0, 0, 0); | ||||||
| 			return tomorrow.getTime(); | 			return tomorrow.getTime(); | ||||||
| 		} | 		} | ||||||
|  | 		if (schedule === "Daily at 4 AM") { | ||||||
|  | 			const now = new Date(); | ||||||
|  | 			const tomorrow = new Date(now); | ||||||
|  | 			tomorrow.setDate(tomorrow.getDate() + 1); | ||||||
|  | 			tomorrow.setHours(4, 0, 0, 0); | ||||||
|  | 			return tomorrow.getTime(); | ||||||
|  | 		} | ||||||
| 		if (schedule === "Every hour") { | 		if (schedule === "Every hour") { | ||||||
| 			const now = new Date(); | 			const now = new Date(); | ||||||
| 			const nextHour = new Date(now); | 			const nextHour = new Date(now); | ||||||
| @@ -269,6 +290,8 @@ const Automation = () => { | |||||||
| 				endpoint = "/automation/trigger/orphaned-repo-cleanup"; | 				endpoint = "/automation/trigger/orphaned-repo-cleanup"; | ||||||
| 			} else if (jobType === "orphaned-packages") { | 			} else if (jobType === "orphaned-packages") { | ||||||
| 				endpoint = "/automation/trigger/orphaned-package-cleanup"; | 				endpoint = "/automation/trigger/orphaned-package-cleanup"; | ||||||
|  | 			} else if (jobType === "docker-inventory") { | ||||||
|  | 				endpoint = "/automation/trigger/docker-inventory-cleanup"; | ||||||
| 			} else if (jobType === "agent-collection") { | 			} else if (jobType === "agent-collection") { | ||||||
| 				endpoint = "/automation/trigger/agent-collection"; | 				endpoint = "/automation/trigger/agent-collection"; | ||||||
| 			} | 			} | ||||||
| @@ -584,6 +607,10 @@ const Automation = () => { | |||||||
| 																automation.queue.includes("orphaned-package") | 																automation.queue.includes("orphaned-package") | ||||||
| 															) { | 															) { | ||||||
| 																triggerManualJob("orphaned-packages"); | 																triggerManualJob("orphaned-packages"); | ||||||
|  | 															} else if ( | ||||||
|  | 																automation.queue.includes("docker-inventory") | ||||||
|  | 															) { | ||||||
|  | 																triggerManualJob("docker-inventory"); | ||||||
| 															} else if ( | 															} else if ( | ||||||
| 																automation.queue.includes("agent-commands") | 																automation.queue.includes("agent-commands") | ||||||
| 															) { | 															) { | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { useQuery } from "@tanstack/react-query"; | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | ||||||
| import { | import { | ||||||
| 	AlertTriangle, | 	AlertTriangle, | ||||||
| 	ArrowDown, | 	ArrowDown, | ||||||
| @@ -11,6 +11,7 @@ import { | |||||||
| 	Search, | 	Search, | ||||||
| 	Server, | 	Server, | ||||||
| 	Shield, | 	Shield, | ||||||
|  | 	Trash2, | ||||||
| 	X, | 	X, | ||||||
| } from "lucide-react"; | } from "lucide-react"; | ||||||
| import { useMemo, useState } from "react"; | import { useMemo, useState } from "react"; | ||||||
| @@ -18,12 +19,15 @@ import { Link } from "react-router-dom"; | |||||||
| import api from "../utils/api"; | import api from "../utils/api"; | ||||||
|  |  | ||||||
| const Docker = () => { | const Docker = () => { | ||||||
|  | 	const queryClient = useQueryClient(); | ||||||
| 	const [searchTerm, setSearchTerm] = useState(""); | 	const [searchTerm, setSearchTerm] = useState(""); | ||||||
| 	const [activeTab, setActiveTab] = useState("containers"); | 	const [activeTab, setActiveTab] = useState("containers"); | ||||||
| 	const [sortField, setSortField] = useState("status"); | 	const [sortField, setSortField] = useState("status"); | ||||||
| 	const [sortDirection, setSortDirection] = useState("asc"); | 	const [sortDirection, setSortDirection] = useState("asc"); | ||||||
| 	const [statusFilter, setStatusFilter] = useState("all"); | 	const [statusFilter, setStatusFilter] = useState("all"); | ||||||
| 	const [sourceFilter, setSourceFilter] = useState("all"); | 	const [sourceFilter, setSourceFilter] = useState("all"); | ||||||
|  | 	const [deleteContainerModal, setDeleteContainerModal] = useState(null); | ||||||
|  | 	const [deleteImageModal, setDeleteImageModal] = useState(null); | ||||||
|  |  | ||||||
| 	// Fetch Docker dashboard data | 	// Fetch Docker dashboard data | ||||||
| 	const { data: dashboard, isLoading: dashboardLoading } = useQuery({ | 	const { data: dashboard, isLoading: dashboardLoading } = useQuery({ | ||||||
| @@ -36,7 +40,11 @@ const Docker = () => { | |||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
| 	// Fetch containers | 	// Fetch containers | ||||||
| 	const { data: containersData, isLoading: containersLoading } = useQuery({ | 	const { | ||||||
|  | 		data: containersData, | ||||||
|  | 		isLoading: containersLoading, | ||||||
|  | 		refetch: refetchContainers, | ||||||
|  | 	} = useQuery({ | ||||||
| 		queryKey: ["docker", "containers", statusFilter], | 		queryKey: ["docker", "containers", statusFilter], | ||||||
| 		queryFn: async () => { | 		queryFn: async () => { | ||||||
| 			const params = new URLSearchParams(); | 			const params = new URLSearchParams(); | ||||||
| @@ -49,7 +57,11 @@ const Docker = () => { | |||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
| 	// Fetch images | 	// Fetch images | ||||||
| 	const { data: imagesData, isLoading: imagesLoading } = useQuery({ | 	const { | ||||||
|  | 		data: imagesData, | ||||||
|  | 		isLoading: imagesLoading, | ||||||
|  | 		refetch: refetchImages, | ||||||
|  | 	} = useQuery({ | ||||||
| 		queryKey: ["docker", "images", sourceFilter], | 		queryKey: ["docker", "images", sourceFilter], | ||||||
| 		queryFn: async () => { | 		queryFn: async () => { | ||||||
| 			const params = new URLSearchParams(); | 			const params = new URLSearchParams(); | ||||||
| @@ -81,6 +93,42 @@ const Docker = () => { | |||||||
| 		enabled: activeTab === "updates", | 		enabled: activeTab === "updates", | ||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
|  | 	// Delete container mutation | ||||||
|  | 	const deleteContainerMutation = useMutation({ | ||||||
|  | 		mutationFn: async (containerId) => { | ||||||
|  | 			const response = await api.delete(`/docker/containers/${containerId}`); | ||||||
|  | 			return response.data; | ||||||
|  | 		}, | ||||||
|  | 		onSuccess: () => { | ||||||
|  | 			queryClient.invalidateQueries(["docker", "containers"]); | ||||||
|  | 			queryClient.invalidateQueries(["docker", "dashboard"]); | ||||||
|  | 			setDeleteContainerModal(null); | ||||||
|  | 		}, | ||||||
|  | 		onError: (error) => { | ||||||
|  | 			alert( | ||||||
|  | 				`Failed to delete container: ${error.response?.data?.error || error.message}`, | ||||||
|  | 			); | ||||||
|  | 		}, | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	// Delete image mutation | ||||||
|  | 	const deleteImageMutation = useMutation({ | ||||||
|  | 		mutationFn: async (imageId) => { | ||||||
|  | 			const response = await api.delete(`/docker/images/${imageId}`); | ||||||
|  | 			return response.data; | ||||||
|  | 		}, | ||||||
|  | 		onSuccess: () => { | ||||||
|  | 			queryClient.invalidateQueries(["docker", "images"]); | ||||||
|  | 			queryClient.invalidateQueries(["docker", "dashboard"]); | ||||||
|  | 			setDeleteImageModal(null); | ||||||
|  | 		}, | ||||||
|  | 		onError: (error) => { | ||||||
|  | 			alert( | ||||||
|  | 				`Failed to delete image: ${error.response?.data?.error || error.message}`, | ||||||
|  | 			); | ||||||
|  | 		}, | ||||||
|  | 	}); | ||||||
|  |  | ||||||
| 	// Filter and sort containers | 	// Filter and sort containers | ||||||
| 	const filteredContainers = useMemo(() => { | 	const filteredContainers = useMemo(() => { | ||||||
| 		if (!containersData?.containers) return []; | 		if (!containersData?.containers) return []; | ||||||
| @@ -288,32 +336,36 @@ const Docker = () => { | |||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
| 	return ( | 	return ( | ||||||
| 		<div className="space-y-6"> | 		<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden"> | ||||||
| 			{/* Header */} | 			{/* Header */} | ||||||
| 			<div className="flex justify-between items-center"> | 			<div className="flex items-center justify-between mb-6"> | ||||||
| 				<div> | 				<div> | ||||||
| 					<h1 className="text-2xl font-bold text-secondary-900 dark:text-white"> | 					<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white"> | ||||||
| 						Docker Inventory | 						Docker Inventory | ||||||
| 					</h1> | 					</h1> | ||||||
| 					<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400"> | 					<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1"> | ||||||
| 						Monitor containers, images, and updates across your infrastructure | 						Monitor containers, images, and updates across your infrastructure | ||||||
| 					</p> | 					</p> | ||||||
| 				</div> | 				</div> | ||||||
|  | 				<div className="flex items-center gap-3"> | ||||||
| 					<button | 					<button | ||||||
| 						type="button" | 						type="button" | ||||||
| 						onClick={() => { | 						onClick={() => { | ||||||
| 						// Trigger refresh of all queries | 							// Trigger refresh based on active tab | ||||||
| 						window.location.reload(); | 							if (activeTab === "containers") refetchContainers(); | ||||||
|  | 							else if (activeTab === "images") refetchImages(); | ||||||
|  | 							else window.location.reload(); | ||||||
| 						}} | 						}} | ||||||
| 					className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500" | 						className="btn-outline flex items-center justify-center p-2" | ||||||
|  | 						title="Refresh data" | ||||||
| 					> | 					> | ||||||
| 					<RefreshCw className="h-4 w-4 mr-2" /> | 						<RefreshCw className="h-4 w-4" /> | ||||||
| 					Refresh |  | ||||||
| 					</button> | 					</button> | ||||||
| 				</div> | 				</div> | ||||||
|  | 			</div> | ||||||
|  |  | ||||||
| 			{/* Dashboard Cards */} | 			{/* Stats Summary */} | ||||||
| 			<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4"> | 			<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6"> | ||||||
| 				<div className="card p-4"> | 				<div className="card p-4"> | ||||||
| 					<div className="flex items-center"> | 					<div className="flex items-center"> | ||||||
| 						<div className="flex-shrink-0"> | 						<div className="flex-shrink-0"> | ||||||
| @@ -400,11 +452,11 @@ const Docker = () => { | |||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
|  |  | ||||||
| 			{/* Tabs and Content */} | 			{/* Docker List */} | ||||||
| 			<div className="card"> | 			<div className="card flex-1 flex flex-col overflow-hidden min-h-0"> | ||||||
| 				{/* Tab Navigation */} | 				{/* Tab Navigation */} | ||||||
| 				<div className="border-b border-secondary-200 dark:border-secondary-700"> | 				<div className="border-b border-secondary-200 dark:border-secondary-600"> | ||||||
| 					<nav className="-mb-px flex space-x-8 px-6" aria-label="Tabs"> | 					<nav className="-mb-px flex space-x-8 px-4" aria-label="Tabs"> | ||||||
| 						{[ | 						{[ | ||||||
| 							{ id: "containers", label: "Containers", icon: Container }, | 							{ id: "containers", label: "Containers", icon: Container }, | ||||||
| 							{ id: "images", label: "Images", icon: Package }, | 							{ id: "images", label: "Images", icon: Package }, | ||||||
| @@ -443,7 +495,7 @@ const Docker = () => { | |||||||
| 				</div> | 				</div> | ||||||
|  |  | ||||||
| 				{/* Filters and Search */} | 				{/* Filters and Search */} | ||||||
| 				<div className="p-6 border-b border-secondary-200 dark:border-secondary-700"> | 				<div className="p-4 border-b border-secondary-200 dark:border-secondary-600"> | ||||||
| 					<div className="flex flex-col sm:flex-row gap-4"> | 					<div className="flex flex-col sm:flex-row gap-4"> | ||||||
| 						<div className="flex-1"> | 						<div className="flex-1"> | ||||||
| 							<div className="relative"> | 							<div className="relative"> | ||||||
| @@ -498,7 +550,7 @@ const Docker = () => { | |||||||
| 				</div> | 				</div> | ||||||
|  |  | ||||||
| 				{/* Tab Content */} | 				{/* Tab Content */} | ||||||
| 				<div className="p-6"> | 				<div className="p-4 flex-1 overflow-auto"> | ||||||
| 					{/* Containers Tab */} | 					{/* Containers Tab */} | ||||||
| 					{activeTab === "containers" && ( | 					{activeTab === "containers" && ( | ||||||
| 						<div className="overflow-x-auto"> | 						<div className="overflow-x-auto"> | ||||||
| @@ -522,83 +574,80 @@ const Docker = () => { | |||||||
| 									</p> | 									</p> | ||||||
| 								</div> | 								</div> | ||||||
| 							) : ( | 							) : ( | ||||||
| 								<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700"> | 								<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600"> | ||||||
| 									<thead className="bg-secondary-50 dark:bg-secondary-900"> | 									<thead className="bg-secondary-50 dark:bg-secondary-700"> | ||||||
| 										<tr> | 										<tr> | ||||||
| 											<th | 											<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" | 												<button | ||||||
| 												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800" | 													type="button" | ||||||
| 													onClick={() => handleSort("name")} | 													onClick={() => handleSort("name")} | ||||||
|  | 													className="flex items-center gap-2 hover:text-secondary-700" | ||||||
| 												> | 												> | ||||||
| 												<div className="flex items-center gap-2"> |  | ||||||
| 													Container Name | 													Container Name | ||||||
| 													{getSortIcon("name")} | 													{getSortIcon("name")} | ||||||
| 												</div> | 												</button> | ||||||
| 											</th> | 											</th> | ||||||
| 											<th | 											<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" | 												<button | ||||||
| 												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800" | 													type="button" | ||||||
| 													onClick={() => handleSort("image")} | 													onClick={() => handleSort("image")} | ||||||
|  | 													className="flex items-center gap-2 hover:text-secondary-700" | ||||||
| 												> | 												> | ||||||
| 												<div className="flex items-center gap-2"> |  | ||||||
| 													Image | 													Image | ||||||
| 													{getSortIcon("image")} | 													{getSortIcon("image")} | ||||||
| 												</div> | 												</button> | ||||||
| 											</th> | 											</th> | ||||||
| 											<th | 											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" | 												<button | ||||||
| 												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800" | 													type="button" | ||||||
| 													onClick={() => handleSort("status")} | 													onClick={() => handleSort("status")} | ||||||
|  | 													className="flex items-center gap-2 hover:text-secondary-700" | ||||||
| 												> | 												> | ||||||
| 												<div className="flex items-center gap-2"> |  | ||||||
| 													Status | 													Status | ||||||
| 													{getSortIcon("status")} | 													{getSortIcon("status")} | ||||||
| 												</div> | 												</button> | ||||||
| 											</th> | 											</th> | ||||||
| 											<th | 											<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" | 												<button | ||||||
| 												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800" | 													type="button" | ||||||
| 													onClick={() => handleSort("host")} | 													onClick={() => handleSort("host")} | ||||||
|  | 													className="flex items-center gap-2 hover:text-secondary-700" | ||||||
| 												> | 												> | ||||||
| 												<div className="flex items-center gap-2"> |  | ||||||
| 													Host | 													Host | ||||||
| 													{getSortIcon("host")} | 													{getSortIcon("host")} | ||||||
| 												</div> | 												</button> | ||||||
| 											</th> | 											</th> | ||||||
| 											<th | 											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" |  | ||||||
| 												className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider" |  | ||||||
| 											> |  | ||||||
| 												Actions | 												Actions | ||||||
| 											</th> | 											</th> | ||||||
| 										</tr> | 										</tr> | ||||||
| 									</thead> | 									</thead> | ||||||
| 									<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700"> | 									<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600"> | ||||||
| 										{filteredContainers.map((container) => ( | 										{filteredContainers.map((container) => ( | ||||||
| 											<tr | 											<tr | ||||||
| 												key={container.id} | 												key={container.id} | ||||||
| 												className="hover:bg-secondary-50 dark:hover:bg-secondary-700" | 												className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors" | ||||||
| 											> | 											> | ||||||
| 												<td className="px-6 py-4 whitespace-nowrap"> | 												<td className="px-4 py-2 whitespace-nowrap"> | ||||||
| 													<div className="flex items-center"> | 													<div className="flex items-center gap-2"> | ||||||
| 														<Container className="h-5 w-5 text-secondary-400 mr-3" /> | 														<Container className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" /> | ||||||
| 														<Link | 														<Link | ||||||
| 															to={`/docker/containers/${container.id}`} | 															to={`/docker/containers/${container.id}`} | ||||||
| 															className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300" | 															className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate" | ||||||
| 														> | 														> | ||||||
| 															{container.name} | 															{container.name} | ||||||
| 														</Link> | 														</Link> | ||||||
| 													</div> | 													</div> | ||||||
| 												</td> | 												</td> | ||||||
| 												<td className="px-6 py-4"> | 												<td className="px-4 py-2"> | ||||||
| 													<div className="text-sm text-secondary-900 dark:text-white"> | 													<div className="text-sm text-secondary-900 dark:text-white"> | ||||||
| 														{container.image_name}:{container.image_tag} | 														{container.image_name}:{container.image_tag} | ||||||
| 													</div> | 													</div> | ||||||
| 												</td> | 												</td> | ||||||
| 												<td className="px-6 py-4 whitespace-nowrap"> | 												<td className="px-4 py-2 whitespace-nowrap text-center"> | ||||||
| 													{getStatusBadge(container.status)} | 													{getStatusBadge(container.status)} | ||||||
| 												</td> | 												</td> | ||||||
| 												<td className="px-6 py-4 whitespace-nowrap"> | 												<td className="px-4 py-2 whitespace-nowrap"> | ||||||
| 													<Link | 													<Link | ||||||
| 														to={`/hosts/${container.host_id}`} | 														to={`/hosts/${container.host_id}`} | ||||||
| 														className="text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300" | 														className="text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300" | ||||||
| @@ -608,14 +657,24 @@ const Docker = () => { | |||||||
| 															"Unknown"} | 															"Unknown"} | ||||||
| 													</Link> | 													</Link> | ||||||
| 												</td> | 												</td> | ||||||
| 												<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> | 												<td className="px-4 py-2 whitespace-nowrap text-center"> | ||||||
|  | 													<div className="flex items-center justify-center gap-3"> | ||||||
| 														<Link | 														<Link | ||||||
| 															to={`/docker/containers/${container.id}`} | 															to={`/docker/containers/${container.id}`} | ||||||
| 														className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center" | 															className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center gap-1" | ||||||
|  | 															title="View details" | ||||||
| 														> | 														> | ||||||
| 														View | 															<ExternalLink className="h-4 w-4" /> | ||||||
| 														<ExternalLink className="ml-1 h-4 w-4" /> |  | ||||||
| 														</Link> | 														</Link> | ||||||
|  | 														<button | ||||||
|  | 															type="button" | ||||||
|  | 															onClick={() => setDeleteContainerModal(container)} | ||||||
|  | 															className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 inline-flex items-center" | ||||||
|  | 															title="Delete container from inventory" | ||||||
|  | 														> | ||||||
|  | 															<Trash2 className="h-4 w-4" /> | ||||||
|  | 														</button> | ||||||
|  | 													</div> | ||||||
| 												</td> | 												</td> | ||||||
| 											</tr> | 											</tr> | ||||||
| 										))} | 										))} | ||||||
| @@ -648,88 +707,79 @@ const Docker = () => { | |||||||
| 									</p> | 									</p> | ||||||
| 								</div> | 								</div> | ||||||
| 							) : ( | 							) : ( | ||||||
| 								<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700"> | 								<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600"> | ||||||
| 									<thead className="bg-secondary-50 dark:bg-secondary-900"> | 									<thead className="bg-secondary-50 dark:bg-secondary-700"> | ||||||
| 										<tr> | 										<tr> | ||||||
| 											<th | 											<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" | 												<button | ||||||
| 												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800" | 													type="button" | ||||||
| 													onClick={() => handleSort("repository")} | 													onClick={() => handleSort("repository")} | ||||||
|  | 													className="flex items-center gap-2 hover:text-secondary-700" | ||||||
| 												> | 												> | ||||||
| 												<div className="flex items-center gap-2"> |  | ||||||
| 													Repository | 													Repository | ||||||
| 													{getSortIcon("repository")} | 													{getSortIcon("repository")} | ||||||
| 												</div> | 												</button> | ||||||
| 											</th> | 											</th> | ||||||
| 											<th | 											<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" | 												<button | ||||||
| 												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800" | 													type="button" | ||||||
| 													onClick={() => handleSort("tag")} | 													onClick={() => handleSort("tag")} | ||||||
|  | 													className="flex items-center gap-2 hover:text-secondary-700" | ||||||
| 												> | 												> | ||||||
| 												<div className="flex items-center gap-2"> |  | ||||||
| 													Tag | 													Tag | ||||||
| 													{getSortIcon("tag")} | 													{getSortIcon("tag")} | ||||||
| 												</div> | 												</button> | ||||||
| 											</th> | 											</th> | ||||||
| 											<th | 											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" |  | ||||||
| 												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider" |  | ||||||
| 											> |  | ||||||
| 												Source | 												Source | ||||||
| 											</th> | 											</th> | ||||||
| 											<th | 											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" | 												<button | ||||||
| 												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800" | 													type="button" | ||||||
| 													onClick={() => handleSort("containers")} | 													onClick={() => handleSort("containers")} | ||||||
|  | 													className="flex items-center gap-2 hover:text-secondary-700" | ||||||
| 												> | 												> | ||||||
| 												<div className="flex items-center gap-2"> |  | ||||||
| 													Containers | 													Containers | ||||||
| 													{getSortIcon("containers")} | 													{getSortIcon("containers")} | ||||||
| 												</div> | 												</button> | ||||||
| 											</th> | 											</th> | ||||||
| 											<th | 											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" |  | ||||||
| 												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider" |  | ||||||
| 											> |  | ||||||
| 												Updates | 												Updates | ||||||
| 											</th> | 											</th> | ||||||
| 											<th | 											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" |  | ||||||
| 												className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider" |  | ||||||
| 											> |  | ||||||
| 												Actions | 												Actions | ||||||
| 											</th> | 											</th> | ||||||
| 										</tr> | 										</tr> | ||||||
| 									</thead> | 									</thead> | ||||||
| 									<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700"> | 									<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600"> | ||||||
| 										{filteredImages.map((image) => ( | 										{filteredImages.map((image) => ( | ||||||
| 											<tr | 											<tr | ||||||
| 												key={image.id} | 												key={image.id} | ||||||
| 												className="hover:bg-secondary-50 dark:hover:bg-secondary-700" | 												className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors" | ||||||
| 											> | 											> | ||||||
| 												<td className="px-6 py-4 whitespace-nowrap"> | 												<td className="px-4 py-2 whitespace-nowrap"> | ||||||
| 													<div className="flex items-center"> | 													<div className="flex items-center gap-2"> | ||||||
| 														<Package className="h-5 w-5 text-secondary-400 mr-3" /> | 														<Package className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" /> | ||||||
| 														<Link | 														<Link | ||||||
| 															to={`/docker/images/${image.id}`} | 															to={`/docker/images/${image.id}`} | ||||||
| 															className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300" | 															className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate" | ||||||
| 														> | 														> | ||||||
| 															{image.repository} | 															{image.repository} | ||||||
| 														</Link> | 														</Link> | ||||||
| 													</div> | 													</div> | ||||||
| 												</td> | 												</td> | ||||||
| 												<td className="px-6 py-4 whitespace-nowrap"> | 												<td className="px-4 py-2 whitespace-nowrap"> | ||||||
| 													<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200"> | 													<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200"> | ||||||
| 														{image.tag} | 														{image.tag} | ||||||
| 													</span> | 													</span> | ||||||
| 												</td> | 												</td> | ||||||
| 												<td className="px-6 py-4 whitespace-nowrap"> | 												<td className="px-4 py-2 whitespace-nowrap text-center"> | ||||||
| 													{getSourceBadge(image.source)} | 													{getSourceBadge(image.source)} | ||||||
| 												</td> | 												</td> | ||||||
| 												<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white"> | 												<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-secondary-900 dark:text-white"> | ||||||
| 													{image._count?.docker_containers || 0} | 													{image._count?.docker_containers || 0} | ||||||
| 												</td> | 												</td> | ||||||
| 												<td className="px-6 py-4 whitespace-nowrap"> | 												<td className="px-4 py-2 whitespace-nowrap text-center"> | ||||||
| 													{image.hasUpdates ? ( | 													{image.hasUpdates ? ( | ||||||
| 														<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"> | 														<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"> | ||||||
| 															<AlertTriangle className="h-3 w-3 mr-1" /> | 															<AlertTriangle className="h-3 w-3 mr-1" /> | ||||||
| @@ -741,14 +791,24 @@ const Docker = () => { | |||||||
| 														</span> | 														</span> | ||||||
| 													)} | 													)} | ||||||
| 												</td> | 												</td> | ||||||
| 												<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> | 												<td className="px-4 py-2 whitespace-nowrap text-center"> | ||||||
|  | 													<div className="flex items-center justify-center gap-3"> | ||||||
| 														<Link | 														<Link | ||||||
| 															to={`/docker/images/${image.id}`} | 															to={`/docker/images/${image.id}`} | ||||||
| 															className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center" | 															className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center" | ||||||
|  | 															title="View details" | ||||||
| 														> | 														> | ||||||
| 														View | 															<ExternalLink className="h-4 w-4" /> | ||||||
| 														<ExternalLink className="ml-1 h-4 w-4" /> |  | ||||||
| 														</Link> | 														</Link> | ||||||
|  | 														<button | ||||||
|  | 															type="button" | ||||||
|  | 															onClick={() => setDeleteImageModal(image)} | ||||||
|  | 															className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 inline-flex items-center" | ||||||
|  | 															title="Delete image from inventory" | ||||||
|  | 														> | ||||||
|  | 															<Trash2 className="h-4 w-4" /> | ||||||
|  | 														</button> | ||||||
|  | 													</div> | ||||||
| 												</td> | 												</td> | ||||||
| 											</tr> | 											</tr> | ||||||
| 										))} | 										))} | ||||||
| @@ -781,86 +841,80 @@ const Docker = () => { | |||||||
| 									</p> | 									</p> | ||||||
| 								</div> | 								</div> | ||||||
| 							) : ( | 							) : ( | ||||||
| 								<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700"> | 								<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600"> | ||||||
| 									<thead className="bg-secondary-50 dark:bg-secondary-900"> | 									<thead className="bg-secondary-50 dark:bg-secondary-700"> | ||||||
| 										<tr> | 										<tr> | ||||||
| 											<th | 											<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" | 												<button | ||||||
| 												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800" | 													type="button" | ||||||
| 													onClick={() => handleSort("name")} | 													onClick={() => handleSort("name")} | ||||||
|  | 													className="flex items-center gap-2 hover:text-secondary-700" | ||||||
| 												> | 												> | ||||||
| 												<div className="flex items-center gap-2"> |  | ||||||
| 													Host Name | 													Host Name | ||||||
| 													{getSortIcon("name")} | 													{getSortIcon("name")} | ||||||
| 												</div> | 												</button> | ||||||
| 											</th> | 											</th> | ||||||
| 											<th | 											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" | 												<button | ||||||
| 												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800" | 													type="button" | ||||||
| 													onClick={() => handleSort("containers")} | 													onClick={() => handleSort("containers")} | ||||||
|  | 													className="flex items-center gap-2 hover:text-secondary-700" | ||||||
| 												> | 												> | ||||||
| 												<div className="flex items-center gap-2"> |  | ||||||
| 													Containers | 													Containers | ||||||
| 													{getSortIcon("containers")} | 													{getSortIcon("containers")} | ||||||
| 												</div> | 												</button> | ||||||
| 											</th> | 											</th> | ||||||
| 											<th | 											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" |  | ||||||
| 												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider" |  | ||||||
| 											> |  | ||||||
| 												Running | 												Running | ||||||
| 											</th> | 											</th> | ||||||
| 											<th | 											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" | 												<button | ||||||
| 												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800" | 													type="button" | ||||||
| 													onClick={() => handleSort("images")} | 													onClick={() => handleSort("images")} | ||||||
|  | 													className="flex items-center gap-2 hover:text-secondary-700" | ||||||
| 												> | 												> | ||||||
| 												<div className="flex items-center gap-2"> |  | ||||||
| 													Images | 													Images | ||||||
| 													{getSortIcon("images")} | 													{getSortIcon("images")} | ||||||
| 												</div> | 												</button> | ||||||
| 											</th> | 											</th> | ||||||
| 											<th | 											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" |  | ||||||
| 												className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider" |  | ||||||
| 											> |  | ||||||
| 												Actions | 												Actions | ||||||
| 											</th> | 											</th> | ||||||
| 										</tr> | 										</tr> | ||||||
| 									</thead> | 									</thead> | ||||||
| 									<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700"> | 									<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600"> | ||||||
| 										{filteredHosts.map((host) => ( | 										{filteredHosts.map((host) => ( | ||||||
| 											<tr | 											<tr | ||||||
| 												key={host.id} | 												key={host.id} | ||||||
| 												className="hover:bg-secondary-50 dark:hover:bg-secondary-700" | 												className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors" | ||||||
| 											> | 											> | ||||||
| 												<td className="px-6 py-4 whitespace-nowrap"> | 												<td className="px-4 py-2 whitespace-nowrap"> | ||||||
| 													<div className="flex items-center"> | 													<div className="flex items-center gap-2"> | ||||||
| 														<Server className="h-5 w-5 text-secondary-400 mr-3" /> | 														<Server className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" /> | ||||||
| 														<Link | 														<Link | ||||||
| 															to={`/docker/hosts/${host.id}`} | 															to={`/docker/hosts/${host.id}`} | ||||||
| 															className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300" | 															className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate" | ||||||
| 														> | 														> | ||||||
| 															{host.friendly_name || host.hostname} | 															{host.friendly_name || host.hostname} | ||||||
| 														</Link> | 														</Link> | ||||||
| 													</div> | 													</div> | ||||||
| 												</td> | 												</td> | ||||||
| 												<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white"> | 												<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-secondary-900 dark:text-white"> | ||||||
| 													{host.dockerStats?.totalContainers || 0} | 													{host.dockerStats?.totalContainers || 0} | ||||||
| 												</td> | 												</td> | ||||||
| 												<td className="px-6 py-4 whitespace-nowrap text-sm text-green-600 dark:text-green-400"> | 												<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-green-600 dark:text-green-400 font-medium"> | ||||||
| 													{host.dockerStats?.runningContainers || 0} | 													{host.dockerStats?.runningContainers || 0} | ||||||
| 												</td> | 												</td> | ||||||
| 												<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white"> | 												<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-secondary-900 dark:text-white"> | ||||||
| 													{host.dockerStats?.totalImages || 0} | 													{host.dockerStats?.totalImages || 0} | ||||||
| 												</td> | 												</td> | ||||||
| 												<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> | 												<td className="px-4 py-2 whitespace-nowrap text-center"> | ||||||
| 													<Link | 													<Link | ||||||
| 														to={`/docker/hosts/${host.id}`} | 														to={`/docker/hosts/${host.id}`} | ||||||
| 														className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center" | 														className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center gap-1" | ||||||
|  | 														title="View details" | ||||||
| 													> | 													> | ||||||
| 														View | 														<ExternalLink className="h-4 w-4" /> | ||||||
| 														<ExternalLink className="ml-1 h-4 w-4" /> |  | ||||||
| 													</Link> | 													</Link> | ||||||
| 												</td> | 												</td> | ||||||
| 											</tr> | 											</tr> | ||||||
| @@ -892,82 +946,64 @@ const Docker = () => { | |||||||
| 									</p> | 									</p> | ||||||
| 								</div> | 								</div> | ||||||
| 							) : ( | 							) : ( | ||||||
| 								<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700"> | 								<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600"> | ||||||
| 									<thead className="bg-secondary-50 dark:bg-secondary-900"> | 									<thead className="bg-secondary-50 dark:bg-secondary-700"> | ||||||
| 										<tr> | 										<tr> | ||||||
| 											<th | 											<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" |  | ||||||
| 												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider" |  | ||||||
| 											> |  | ||||||
| 												Image | 												Image | ||||||
| 											</th> | 											</th> | ||||||
| 											<th | 											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" |  | ||||||
| 												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider" |  | ||||||
| 											> |  | ||||||
| 												Tag | 												Tag | ||||||
| 											</th> | 											</th> | ||||||
| 											<th | 											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" |  | ||||||
| 												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider" |  | ||||||
| 											> |  | ||||||
| 												Detection Method | 												Detection Method | ||||||
| 											</th> | 											</th> | ||||||
| 											<th | 											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" |  | ||||||
| 												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider" |  | ||||||
| 											> |  | ||||||
| 												Status | 												Status | ||||||
| 											</th> | 											</th> | ||||||
| 											<th | 											<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" |  | ||||||
| 												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider" |  | ||||||
| 											> |  | ||||||
| 												Affected | 												Affected | ||||||
| 											</th> | 											</th> | ||||||
| 											<th | 											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"> | ||||||
| 												scope="col" |  | ||||||
| 												className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider" |  | ||||||
| 											> |  | ||||||
| 												Actions | 												Actions | ||||||
| 											</th> | 											</th> | ||||||
| 										</tr> | 										</tr> | ||||||
| 									</thead> | 									</thead> | ||||||
| 									<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700"> | 									<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600"> | ||||||
| 										{updatesData.updates.map((update) => ( | 										{updatesData.updates.map((update) => ( | ||||||
| 											<tr | 											<tr | ||||||
| 												key={update.id} | 												key={update.id} | ||||||
| 												className="hover:bg-secondary-50 dark:hover:bg-secondary-700" | 												className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors" | ||||||
| 											> | 											> | ||||||
| 												<td className="px-6 py-4 whitespace-nowrap"> | 												<td className="px-4 py-2 whitespace-nowrap"> | ||||||
| 													<div className="flex items-center"> | 													<div className="flex items-center gap-2"> | ||||||
| 														<Package className="h-5 w-5 text-secondary-400 mr-3" /> | 														<Package className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" /> | ||||||
| 														<Link | 														<Link | ||||||
| 															to={`/docker/images/${update.image_id}`} | 															to={`/docker/images/${update.image_id}`} | ||||||
| 															className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300" | 															className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate" | ||||||
| 														> | 														> | ||||||
| 															{update.docker_images?.repository} | 															{update.docker_images?.repository} | ||||||
| 														</Link> | 														</Link> | ||||||
| 													</div> | 													</div> | ||||||
| 												</td> | 												</td> | ||||||
| 												<td className="px-6 py-4 whitespace-nowrap"> | 												<td className="px-4 py-2 whitespace-nowrap text-center"> | ||||||
| 													<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200"> | 													<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200"> | ||||||
| 														{update.current_tag} | 														{update.current_tag} | ||||||
| 													</span> | 													</span> | ||||||
| 												</td> | 												</td> | ||||||
| 												<td className="px-6 py-4 whitespace-nowrap"> | 												<td className="px-4 py-2 whitespace-nowrap text-center"> | ||||||
| 													<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"> | 													<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"> | ||||||
| 														<Package className="h-3 w-3 mr-1" /> | 														<Package className="h-3 w-3 mr-1" /> | ||||||
| 														Digest Comparison | 														Digest | ||||||
| 													</span> | 													</span> | ||||||
| 												</td> | 												</td> | ||||||
| 												<td className="px-6 py-4 whitespace-nowrap"> | 												<td className="px-4 py-2 whitespace-nowrap text-center"> | ||||||
| 													<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"> | 													<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"> | ||||||
| 														<AlertTriangle className="h-3 w-3 mr-1" /> | 														<AlertTriangle className="h-3 w-3 mr-1" /> | ||||||
| 														Update Available | 														Available | ||||||
| 													</span> | 													</span> | ||||||
| 												</td> | 												</td> | ||||||
| 												<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white"> | 												<td className="px-4 py-2 whitespace-nowrap text-sm text-secondary-900 dark:text-white"> | ||||||
| 													{update.affectedContainersCount} container | 													{update.affectedContainersCount} container | ||||||
| 													{update.affectedContainersCount !== 1 ? "s" : ""} | 													{update.affectedContainersCount !== 1 ? "s" : ""} | ||||||
| 													{update.affectedHosts?.length > 0 && ( | 													{update.affectedHosts?.length > 0 && ( | ||||||
| @@ -978,13 +1014,13 @@ const Docker = () => { | |||||||
| 														</span> | 														</span> | ||||||
| 													)} | 													)} | ||||||
| 												</td> | 												</td> | ||||||
| 												<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> | 												<td className="px-4 py-2 whitespace-nowrap text-center"> | ||||||
| 													<Link | 													<Link | ||||||
| 														to={`/docker/images/${update.image_id}`} | 														to={`/docker/images/${update.image_id}`} | ||||||
| 														className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center" | 														className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center gap-1" | ||||||
|  | 														title="View details" | ||||||
| 													> | 													> | ||||||
| 														View | 														<ExternalLink className="h-4 w-4" /> | ||||||
| 														<ExternalLink className="ml-1 h-4 w-4" /> |  | ||||||
| 													</Link> | 													</Link> | ||||||
| 												</td> | 												</td> | ||||||
| 											</tr> | 											</tr> | ||||||
| @@ -996,6 +1032,141 @@ const Docker = () => { | |||||||
| 					)} | 					)} | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
|  |  | ||||||
|  | 			{/* Delete Container Modal */} | ||||||
|  | 			{deleteContainerModal && ( | ||||||
|  | 				<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 p-6 max-w-md w-full mx-4"> | ||||||
|  | 						<div className="flex items-start mb-4"> | ||||||
|  | 							<div className="flex-shrink-0"> | ||||||
|  | 								<AlertTriangle className="h-6 w-6 text-red-600" /> | ||||||
|  | 							</div> | ||||||
|  | 							<div className="ml-3 flex-1"> | ||||||
|  | 								<h3 className="text-lg font-medium text-secondary-900 dark:text-white"> | ||||||
|  | 									Delete Container | ||||||
|  | 								</h3> | ||||||
|  | 								<div className="mt-2 text-sm text-secondary-600 dark:text-secondary-300"> | ||||||
|  | 									<p className="mb-2"> | ||||||
|  | 										Are you sure you want to delete this container from the | ||||||
|  | 										inventory? | ||||||
|  | 									</p> | ||||||
|  | 									<div className="bg-secondary-100 dark:bg-secondary-700 p-3 rounded-md"> | ||||||
|  | 										<p className="font-medium text-secondary-900 dark:text-white"> | ||||||
|  | 											{deleteContainerModal.name} | ||||||
|  | 										</p> | ||||||
|  | 										<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-1"> | ||||||
|  | 											Image: {deleteContainerModal.image_name}: | ||||||
|  | 											{deleteContainerModal.image_tag} | ||||||
|  | 										</p> | ||||||
|  | 										<p className="text-xs text-secondary-600 dark:text-secondary-400"> | ||||||
|  | 											Host:{" "} | ||||||
|  | 											{deleteContainerModal.host?.friendly_name || "Unknown"} | ||||||
|  | 										</p> | ||||||
|  | 									</div> | ||||||
|  | 									<p className="mt-3 text-red-600 dark:text-red-400 font-medium"> | ||||||
|  | 										⚠️ This only removes the container from PatchMon's inventory. | ||||||
|  | 										It does NOT stop or delete the actual Docker container on | ||||||
|  | 										the host. | ||||||
|  | 									</p> | ||||||
|  | 								</div> | ||||||
|  | 							</div> | ||||||
|  | 						</div> | ||||||
|  | 						<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-3"> | ||||||
|  | 							<button | ||||||
|  | 								type="button" | ||||||
|  | 								onClick={() => | ||||||
|  | 									deleteContainerMutation.mutate(deleteContainerModal.id) | ||||||
|  | 								} | ||||||
|  | 								disabled={deleteContainerMutation.isPending} | ||||||
|  | 								className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed" | ||||||
|  | 							> | ||||||
|  | 								{deleteContainerMutation.isPending | ||||||
|  | 									? "Deleting..." | ||||||
|  | 									: "Delete from Inventory"} | ||||||
|  | 							</button> | ||||||
|  | 							<button | ||||||
|  | 								type="button" | ||||||
|  | 								onClick={() => setDeleteContainerModal(null)} | ||||||
|  | 								disabled={deleteContainerMutation.isPending} | ||||||
|  | 								className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-700 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed" | ||||||
|  | 							> | ||||||
|  | 								Cancel | ||||||
|  | 							</button> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  | 			)} | ||||||
|  |  | ||||||
|  | 			{/* Delete Image Modal */} | ||||||
|  | 			{deleteImageModal && ( | ||||||
|  | 				<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 p-6 max-w-md w-full mx-4"> | ||||||
|  | 						<div className="flex items-start mb-4"> | ||||||
|  | 							<div className="flex-shrink-0"> | ||||||
|  | 								<AlertTriangle className="h-6 w-6 text-red-600" /> | ||||||
|  | 							</div> | ||||||
|  | 							<div className="ml-3 flex-1"> | ||||||
|  | 								<h3 className="text-lg font-medium text-secondary-900 dark:text-white"> | ||||||
|  | 									Delete Image | ||||||
|  | 								</h3> | ||||||
|  | 								<div className="mt-2 text-sm text-secondary-600 dark:text-secondary-300"> | ||||||
|  | 									<p className="mb-2"> | ||||||
|  | 										Are you sure you want to delete this image from the | ||||||
|  | 										inventory? | ||||||
|  | 									</p> | ||||||
|  | 									<div className="bg-secondary-100 dark:bg-secondary-700 p-3 rounded-md"> | ||||||
|  | 										<p className="font-medium text-secondary-900 dark:text-white"> | ||||||
|  | 											{deleteImageModal.repository}:{deleteImageModal.tag} | ||||||
|  | 										</p> | ||||||
|  | 										<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-1"> | ||||||
|  | 											Source: {deleteImageModal.source} | ||||||
|  | 										</p> | ||||||
|  | 										<p className="text-xs text-secondary-600 dark:text-secondary-400"> | ||||||
|  | 											Containers using this:{" "} | ||||||
|  | 											{deleteImageModal._count?.docker_containers || 0} | ||||||
|  | 										</p> | ||||||
|  | 									</div> | ||||||
|  | 									{deleteImageModal._count?.docker_containers > 0 ? ( | ||||||
|  | 										<p className="mt-3 text-red-600 dark:text-red-400 font-medium"> | ||||||
|  | 											⚠️ Cannot delete: This image is in use by{" "} | ||||||
|  | 											{deleteImageModal._count.docker_containers} container(s). | ||||||
|  | 											Delete the containers first. | ||||||
|  | 										</p> | ||||||
|  | 									) : ( | ||||||
|  | 										<p className="mt-3 text-red-600 dark:text-red-400 font-medium"> | ||||||
|  | 											⚠️ This only removes the image from PatchMon's inventory. | ||||||
|  | 											It does NOT delete the actual Docker image from hosts. | ||||||
|  | 										</p> | ||||||
|  | 									)} | ||||||
|  | 								</div> | ||||||
|  | 							</div> | ||||||
|  | 						</div> | ||||||
|  | 						<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-3"> | ||||||
|  | 							<button | ||||||
|  | 								type="button" | ||||||
|  | 								onClick={() => deleteImageMutation.mutate(deleteImageModal.id)} | ||||||
|  | 								disabled={ | ||||||
|  | 									deleteImageMutation.isPending || | ||||||
|  | 									deleteImageModal._count?.docker_containers > 0 | ||||||
|  | 								} | ||||||
|  | 								className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed" | ||||||
|  | 							> | ||||||
|  | 								{deleteImageMutation.isPending | ||||||
|  | 									? "Deleting..." | ||||||
|  | 									: "Delete from Inventory"} | ||||||
|  | 							</button> | ||||||
|  | 							<button | ||||||
|  | 								type="button" | ||||||
|  | 								onClick={() => setDeleteImageModal(null)} | ||||||
|  | 								disabled={deleteImageMutation.isPending} | ||||||
|  | 								className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-700 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed" | ||||||
|  | 							> | ||||||
|  | 								Cancel | ||||||
|  | 							</button> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  | 			)} | ||||||
| 		</div> | 		</div> | ||||||
| 	); | 	); | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -187,6 +187,16 @@ const HostDetail = () => { | |||||||
| 		}, | 		}, | ||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
|  | 	// Force agent update mutation | ||||||
|  | 	const forceAgentUpdateMutation = useMutation({ | ||||||
|  | 		mutationFn: () => | ||||||
|  | 			adminHostsAPI.forceAgentUpdate(hostId).then((res) => res.data), | ||||||
|  | 		onSuccess: () => { | ||||||
|  | 			queryClient.invalidateQueries(["host", hostId]); | ||||||
|  | 			queryClient.invalidateQueries(["hosts"]); | ||||||
|  | 		}, | ||||||
|  | 	}); | ||||||
|  |  | ||||||
| 	const updateFriendlyNameMutation = useMutation({ | 	const updateFriendlyNameMutation = useMutation({ | ||||||
| 		mutationFn: (friendlyName) => | 		mutationFn: (friendlyName) => | ||||||
| 			adminHostsAPI | 			adminHostsAPI | ||||||
| @@ -703,6 +713,29 @@ const HostDetail = () => { | |||||||
| 											/> | 											/> | ||||||
| 										</button> | 										</button> | ||||||
| 									</div> | 									</div> | ||||||
|  |  | ||||||
|  | 									<div> | ||||||
|  | 										<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5"> | ||||||
|  | 											Force Update | ||||||
|  | 										</p> | ||||||
|  | 										<button | ||||||
|  | 											type="button" | ||||||
|  | 											onClick={() => forceAgentUpdateMutation.mutate()} | ||||||
|  | 											disabled={forceAgentUpdateMutation.isPending} | ||||||
|  | 											className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-md hover:bg-primary-100 dark:hover:bg-primary-900/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" | ||||||
|  | 										> | ||||||
|  | 											<RefreshCw | ||||||
|  | 												className={`h-3 w-3 ${ | ||||||
|  | 													forceAgentUpdateMutation.isPending | ||||||
|  | 														? "animate-spin" | ||||||
|  | 														: "" | ||||||
|  | 												}`} | ||||||
|  | 											/> | ||||||
|  | 											{forceAgentUpdateMutation.isPending | ||||||
|  | 												? "Updating..." | ||||||
|  | 												: "Update Now"} | ||||||
|  | 										</button> | ||||||
|  | 									</div> | ||||||
| 								</div> | 								</div> | ||||||
| 							</div> | 							</div> | ||||||
| 						)} | 						)} | ||||||
|   | |||||||
| @@ -402,105 +402,71 @@ const Hosts = () => { | |||||||
| 		const token = localStorage.getItem("token"); | 		const token = localStorage.getItem("token"); | ||||||
| 		if (!token) return; | 		if (!token) return; | ||||||
|  |  | ||||||
|  | 		// Fetch initial WebSocket status for all hosts | ||||||
| 		// Fetch initial WebSocket status for all hosts | 		// Fetch initial WebSocket status for all hosts | ||||||
| 		const fetchInitialStatus = async () => { | 		const fetchInitialStatus = async () => { | ||||||
| 			const statusPromises = hosts | 			const apiIds = hosts | ||||||
| 				.filter((host) => host.api_id) | 				.filter((host) => host.api_id) | ||||||
| 				.map(async (host) => { | 				.map((host) => host.api_id); | ||||||
|  |  | ||||||
|  | 			if (apiIds.length === 0) return; | ||||||
|  |  | ||||||
| 			try { | 			try { | ||||||
| 						const response = await fetch(`/api/v1/ws/status/${host.api_id}`, { | 				const response = await fetch( | ||||||
|  | 					`/api/v1/ws/status?apiIds=${apiIds.join(",")}`, | ||||||
|  | 					{ | ||||||
| 						headers: { | 						headers: { | ||||||
| 							Authorization: `Bearer ${token}`, | 							Authorization: `Bearer ${token}`, | ||||||
| 						}, | 						}, | ||||||
| 						}); | 					}, | ||||||
|  | 				); | ||||||
| 				if (response.ok) { | 				if (response.ok) { | ||||||
| 							const data = await response.json(); | 					const result = await response.json(); | ||||||
| 							return { apiId: host.api_id, status: data.data }; | 					setWsStatusMap(result.data); | ||||||
| 				} | 				} | ||||||
| 			} catch (_error) { | 			} catch (_error) { | ||||||
| 				// Silently handle errors | 				// Silently handle errors | ||||||
| 			} | 			} | ||||||
| 					return { |  | ||||||
| 						apiId: host.api_id, |  | ||||||
| 						status: { connected: false, secure: false }, |  | ||||||
| 					}; |  | ||||||
| 				}); |  | ||||||
|  |  | ||||||
| 			const results = await Promise.all(statusPromises); |  | ||||||
| 			const initialStatusMap = {}; |  | ||||||
| 			results.forEach(({ apiId, status }) => { |  | ||||||
| 				initialStatusMap[apiId] = status; |  | ||||||
| 			}); |  | ||||||
|  |  | ||||||
| 			setWsStatusMap(initialStatusMap); |  | ||||||
| 		}; | 		}; | ||||||
|  |  | ||||||
| 		fetchInitialStatus(); | 		fetchInitialStatus(); | ||||||
| 	}, [hosts]); | 	}, [hosts]); | ||||||
|  |  | ||||||
| 	// Subscribe to WebSocket status changes for all hosts via SSE | 	// Subscribe to WebSocket status changes for all hosts via polling (lightweight alternative to SSE) | ||||||
| 	useEffect(() => { | 	useEffect(() => { | ||||||
| 		if (!hosts || hosts.length === 0) return; | 		if (!hosts || hosts.length === 0) return; | ||||||
|  |  | ||||||
| 		const token = localStorage.getItem("token"); | 		const token = localStorage.getItem("token"); | ||||||
| 		if (!token) return; | 		if (!token) return; | ||||||
|  |  | ||||||
| 		const eventSources = new Map(); | 		// Use polling instead of SSE to avoid connection pool issues | ||||||
| 		let isMounted = true; | 		// Poll every 10 seconds instead of 19 persistent connections | ||||||
|  | 		const pollInterval = setInterval(() => { | ||||||
|  | 			const apiIds = hosts | ||||||
|  | 				.filter((host) => host.api_id) | ||||||
|  | 				.map((host) => host.api_id); | ||||||
|  |  | ||||||
| 		const connectHost = (apiId) => { | 			if (apiIds.length === 0) return; | ||||||
| 			if (!isMounted || eventSources.has(apiId)) return; |  | ||||||
|  |  | ||||||
| 			try { | 			fetch(`/api/v1/ws/status?apiIds=${apiIds.join(",")}`, { | ||||||
| 				const es = new EventSource( | 				headers: { | ||||||
| 					`/api/v1/ws/status/${apiId}/stream?token=${encodeURIComponent(token)}`, | 					Authorization: `Bearer ${token}`, | ||||||
| 				); | 				}, | ||||||
|  | 			}) | ||||||
| 				es.onmessage = (event) => { | 				.then((response) => response.json()) | ||||||
| 					try { | 				.then((result) => { | ||||||
| 						const data = JSON.parse(event.data); | 					if (result.success && result.data) { | ||||||
| 						if (isMounted) { | 						setWsStatusMap(result.data); | ||||||
| 							setWsStatusMap((prev) => { | 					} | ||||||
| 								const newMap = { ...prev, [apiId]: data }; | 				}) | ||||||
| 								return newMap; | 				.catch(() => { | ||||||
|  | 					// Silently handle errors | ||||||
| 				}); | 				}); | ||||||
| 						} | 		}, 10000); // Poll every 10 seconds | ||||||
| 					} catch (_err) { |  | ||||||
| 						// Silently handle parse errors |  | ||||||
| 					} |  | ||||||
| 				}; |  | ||||||
|  |  | ||||||
| 				es.onerror = (_error) => { |  | ||||||
| 					console.log(`[SSE] Connection error for ${apiId}, retrying...`); |  | ||||||
| 					es?.close(); |  | ||||||
| 					eventSources.delete(apiId); |  | ||||||
| 					if (isMounted) { |  | ||||||
| 						// Retry connection after 5 seconds with exponential backoff |  | ||||||
| 						setTimeout(() => connectHost(apiId), 5000); |  | ||||||
| 					} |  | ||||||
| 				}; |  | ||||||
|  |  | ||||||
| 				eventSources.set(apiId, es); |  | ||||||
| 			} catch (_err) { |  | ||||||
| 				// Silently handle connection errors |  | ||||||
| 			} |  | ||||||
| 		}; |  | ||||||
|  |  | ||||||
| 		// Connect to all hosts |  | ||||||
| 		for (const host of hosts) { |  | ||||||
| 			if (host.api_id) { |  | ||||||
| 				connectHost(host.api_id); |  | ||||||
| 			} else { |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		// Cleanup function | 		// Cleanup function | ||||||
| 		return () => { | 		return () => { | ||||||
| 			isMounted = false; | 			clearInterval(pollInterval); | ||||||
| 			for (const es of eventSources.values()) { |  | ||||||
| 				es.close(); |  | ||||||
| 			} |  | ||||||
| 			eventSources.clear(); |  | ||||||
| 		}; | 		}; | ||||||
| 	}, [hosts]); | 	}, [hosts]); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,18 +1,25 @@ | |||||||
| import { | import { | ||||||
| 	AlertCircle, | 	AlertCircle, | ||||||
| 	ArrowLeft, | 	ArrowLeft, | ||||||
|  | 	BookOpen, | ||||||
| 	Eye, | 	Eye, | ||||||
| 	EyeOff, | 	EyeOff, | ||||||
|  | 	Github, | ||||||
|  | 	Globe, | ||||||
| 	Lock, | 	Lock, | ||||||
| 	Mail, | 	Mail, | ||||||
| 	Smartphone, | 	Route, | ||||||
|  | 	Star, | ||||||
| 	User, | 	User, | ||||||
| } from "lucide-react"; | } from "lucide-react"; | ||||||
|  |  | ||||||
| import { useEffect, useId, useState } from "react"; | import { useEffect, useId, useRef, useState } from "react"; | ||||||
|  | import { FaReddit, FaYoutube } from "react-icons/fa"; | ||||||
|  |  | ||||||
| import { useNavigate } from "react-router-dom"; | import { useNavigate } from "react-router-dom"; | ||||||
|  | import DiscordIcon from "../components/DiscordIcon"; | ||||||
| import { useAuth } from "../contexts/AuthContext"; | import { useAuth } from "../contexts/AuthContext"; | ||||||
|  | import { useColorTheme } from "../contexts/ColorThemeContext"; | ||||||
| import { authAPI, isCorsError } from "../utils/api"; | import { authAPI, isCorsError } from "../utils/api"; | ||||||
|  |  | ||||||
| const Login = () => { | const Login = () => { | ||||||
| @@ -42,9 +49,108 @@ const Login = () => { | |||||||
| 	const [requiresTfa, setRequiresTfa] = useState(false); | 	const [requiresTfa, setRequiresTfa] = useState(false); | ||||||
| 	const [tfaUsername, setTfaUsername] = useState(""); | 	const [tfaUsername, setTfaUsername] = useState(""); | ||||||
| 	const [signupEnabled, setSignupEnabled] = useState(false); | 	const [signupEnabled, setSignupEnabled] = useState(false); | ||||||
|  | 	const [latestRelease, setLatestRelease] = useState(null); | ||||||
|  | 	const [githubStars, setGithubStars] = useState(null); | ||||||
|  | 	const canvasRef = useRef(null); | ||||||
|  | 	const { themeConfig } = useColorTheme(); | ||||||
|  |  | ||||||
| 	const navigate = useNavigate(); | 	const navigate = useNavigate(); | ||||||
|  |  | ||||||
|  | 	// Generate clean radial gradient background with subtle triangular accents | ||||||
|  | 	useEffect(() => { | ||||||
|  | 		const generateBackground = () => { | ||||||
|  | 			if (!canvasRef.current || !themeConfig?.login) return; | ||||||
|  |  | ||||||
|  | 			const canvas = canvasRef.current; | ||||||
|  | 			canvas.width = canvas.offsetWidth; | ||||||
|  | 			canvas.height = canvas.offsetHeight; | ||||||
|  | 			const ctx = canvas.getContext("2d"); | ||||||
|  |  | ||||||
|  | 			// Get theme colors - pick first color from each palette | ||||||
|  | 			const xColors = themeConfig.login.xColors || [ | ||||||
|  | 				"#667eea", | ||||||
|  | 				"#764ba2", | ||||||
|  | 				"#f093fb", | ||||||
|  | 				"#4facfe", | ||||||
|  | 			]; | ||||||
|  | 			const yColors = themeConfig.login.yColors || [ | ||||||
|  | 				"#667eea", | ||||||
|  | 				"#764ba2", | ||||||
|  | 				"#f093fb", | ||||||
|  | 				"#4facfe", | ||||||
|  | 			]; | ||||||
|  |  | ||||||
|  | 			// Use date for daily color rotation | ||||||
|  | 			const today = new Date(); | ||||||
|  | 			const seed = | ||||||
|  | 				today.getFullYear() * 10000 + today.getMonth() * 100 + today.getDate(); | ||||||
|  | 			const random = (s) => { | ||||||
|  | 				const x = Math.sin(s) * 10000; | ||||||
|  | 				return x - Math.floor(x); | ||||||
|  | 			}; | ||||||
|  |  | ||||||
|  | 			const color1 = xColors[Math.floor(random(seed) * xColors.length)]; | ||||||
|  | 			const color2 = yColors[Math.floor(random(seed + 1000) * yColors.length)]; | ||||||
|  |  | ||||||
|  | 			// Create clean radial gradient from center to bottom-right corner | ||||||
|  | 			const gradient = ctx.createRadialGradient( | ||||||
|  | 				canvas.width * 0.3, // Center slightly left | ||||||
|  | 				canvas.height * 0.3, // Center slightly up | ||||||
|  | 				0, | ||||||
|  | 				canvas.width * 0.5, // Expand to cover screen | ||||||
|  | 				canvas.height * 0.5, | ||||||
|  | 				Math.max(canvas.width, canvas.height) * 1.2, | ||||||
|  | 			); | ||||||
|  |  | ||||||
|  | 			// Subtle gradient with darker corners | ||||||
|  | 			gradient.addColorStop(0, color1); | ||||||
|  | 			gradient.addColorStop(0.6, color2); | ||||||
|  | 			gradient.addColorStop(1, "#0a0a0a"); // Very dark edges | ||||||
|  |  | ||||||
|  | 			ctx.fillStyle = gradient; | ||||||
|  | 			ctx.fillRect(0, 0, canvas.width, canvas.height); | ||||||
|  |  | ||||||
|  | 			// Add subtle triangular shapes as accents across entire background | ||||||
|  | 			const cellSize = 180; | ||||||
|  | 			const cols = Math.ceil(canvas.width / cellSize) + 1; | ||||||
|  | 			const rows = Math.ceil(canvas.height / cellSize) + 1; | ||||||
|  |  | ||||||
|  | 			for (let y = 0; y < rows; y++) { | ||||||
|  | 				for (let x = 0; x < cols; x++) { | ||||||
|  | 					const idx = y * cols + x; | ||||||
|  | 					// Draw more triangles (less sparse) | ||||||
|  | 					if (random(seed + idx + 5000) > 0.4) { | ||||||
|  | 						const baseX = | ||||||
|  | 							x * cellSize + random(seed + idx * 3) * cellSize * 0.8; | ||||||
|  | 						const baseY = | ||||||
|  | 							y * cellSize + random(seed + idx * 3 + 100) * cellSize * 0.8; | ||||||
|  | 						const size = 50 + random(seed + idx * 4) * 100; | ||||||
|  |  | ||||||
|  | 						ctx.beginPath(); | ||||||
|  | 						ctx.moveTo(baseX, baseY); | ||||||
|  | 						ctx.lineTo(baseX + size, baseY); | ||||||
|  | 						ctx.lineTo(baseX + size / 2, baseY - size * 0.866); | ||||||
|  | 						ctx.closePath(); | ||||||
|  |  | ||||||
|  | 						// More visible white with slightly higher opacity | ||||||
|  | 						ctx.fillStyle = `rgba(255, 255, 255, ${0.05 + random(seed + idx * 5) * 0.08})`; | ||||||
|  | 						ctx.fill(); | ||||||
|  | 					} | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		}; | ||||||
|  |  | ||||||
|  | 		generateBackground(); | ||||||
|  |  | ||||||
|  | 		// Regenerate on window resize | ||||||
|  | 		const handleResize = () => { | ||||||
|  | 			generateBackground(); | ||||||
|  | 		}; | ||||||
|  |  | ||||||
|  | 		window.addEventListener("resize", handleResize); | ||||||
|  | 		return () => window.removeEventListener("resize", handleResize); | ||||||
|  | 	}, [themeConfig]); | ||||||
|  |  | ||||||
| 	// Check if signup is enabled | 	// Check if signup is enabled | ||||||
| 	useEffect(() => { | 	useEffect(() => { | ||||||
| 		const checkSignupEnabled = async () => { | 		const checkSignupEnabled = async () => { | ||||||
| @@ -63,6 +169,99 @@ const Login = () => { | |||||||
| 		checkSignupEnabled(); | 		checkSignupEnabled(); | ||||||
| 	}, []); | 	}, []); | ||||||
|  |  | ||||||
|  | 	// Fetch latest release and stars from GitHub | ||||||
|  | 	useEffect(() => { | ||||||
|  | 		const fetchGitHubData = async () => { | ||||||
|  | 			try { | ||||||
|  | 				// Try to get cached data first | ||||||
|  | 				const cachedRelease = localStorage.getItem("githubLatestRelease"); | ||||||
|  | 				const cachedStars = localStorage.getItem("githubStarsCount"); | ||||||
|  | 				const cacheTime = localStorage.getItem("githubReleaseCacheTime"); | ||||||
|  | 				const now = Date.now(); | ||||||
|  |  | ||||||
|  | 				// Load cached data immediately | ||||||
|  | 				if (cachedRelease) { | ||||||
|  | 					setLatestRelease(JSON.parse(cachedRelease)); | ||||||
|  | 				} | ||||||
|  | 				if (cachedStars) { | ||||||
|  | 					setGithubStars(parseInt(cachedStars, 10)); | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				// Use cache if less than 1 hour old | ||||||
|  | 				if (cacheTime && now - parseInt(cacheTime, 10) < 3600000) { | ||||||
|  | 					return; | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				// Fetch repository info (includes star count) | ||||||
|  | 				const repoResponse = await fetch( | ||||||
|  | 					"https://api.github.com/repos/PatchMon/PatchMon", | ||||||
|  | 					{ | ||||||
|  | 						headers: { | ||||||
|  | 							Accept: "application/vnd.github.v3+json", | ||||||
|  | 						}, | ||||||
|  | 					}, | ||||||
|  | 				); | ||||||
|  |  | ||||||
|  | 				if (repoResponse.ok) { | ||||||
|  | 					const repoData = await repoResponse.json(); | ||||||
|  | 					setGithubStars(repoData.stargazers_count); | ||||||
|  | 					localStorage.setItem( | ||||||
|  | 						"githubStarsCount", | ||||||
|  | 						repoData.stargazers_count.toString(), | ||||||
|  | 					); | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				// Fetch latest release | ||||||
|  | 				const releaseResponse = await fetch( | ||||||
|  | 					"https://api.github.com/repos/PatchMon/PatchMon/releases/latest", | ||||||
|  | 					{ | ||||||
|  | 						headers: { | ||||||
|  | 							Accept: "application/vnd.github.v3+json", | ||||||
|  | 						}, | ||||||
|  | 					}, | ||||||
|  | 				); | ||||||
|  |  | ||||||
|  | 				if (releaseResponse.ok) { | ||||||
|  | 					const data = await releaseResponse.json(); | ||||||
|  | 					const releaseInfo = { | ||||||
|  | 						version: data.tag_name, | ||||||
|  | 						name: data.name, | ||||||
|  | 						publishedAt: new Date(data.published_at).toLocaleDateString( | ||||||
|  | 							"en-US", | ||||||
|  | 							{ | ||||||
|  | 								year: "numeric", | ||||||
|  | 								month: "long", | ||||||
|  | 								day: "numeric", | ||||||
|  | 							}, | ||||||
|  | 						), | ||||||
|  | 						body: data.body?.split("\n").slice(0, 3).join("\n") || "", // First 3 lines | ||||||
|  | 					}; | ||||||
|  |  | ||||||
|  | 					setLatestRelease(releaseInfo); | ||||||
|  | 					localStorage.setItem( | ||||||
|  | 						"githubLatestRelease", | ||||||
|  | 						JSON.stringify(releaseInfo), | ||||||
|  | 					); | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				localStorage.setItem("githubReleaseCacheTime", now.toString()); | ||||||
|  | 			} catch (error) { | ||||||
|  | 				console.error("Failed to fetch GitHub data:", error); | ||||||
|  | 				// Set fallback data if nothing cached | ||||||
|  | 				if (!latestRelease) { | ||||||
|  | 					setLatestRelease({ | ||||||
|  | 						version: "v1.3.0", | ||||||
|  | 						name: "Latest Release", | ||||||
|  | 						publishedAt: "Recently", | ||||||
|  | 						body: "Monitor and manage your Linux package updates", | ||||||
|  | 					}); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		}; | ||||||
|  |  | ||||||
|  | 		fetchGitHubData(); | ||||||
|  | 	}, [latestRelease]); | ||||||
|  |  | ||||||
| 	const handleSubmit = async (e) => { | 	const handleSubmit = async (e) => { | ||||||
| 		e.preventDefault(); | 		e.preventDefault(); | ||||||
| 		setIsLoading(true); | 		setIsLoading(true); | ||||||
| @@ -239,16 +438,227 @@ const Login = () => { | |||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
| 	return ( | 	return ( | ||||||
| 		<div className="min-h-screen flex items-center justify-center bg-secondary-50 py-12 px-4 sm:px-6 lg:px-8"> | 		<div className="min-h-screen relative flex"> | ||||||
| 			<div className="max-w-md w-full space-y-8"> | 			{/* Full-screen Trianglify Background */} | ||||||
|  | 			<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" /> | ||||||
|  | 			<div className="absolute inset-0 bg-gradient-to-br from-black/40 to-black/60" /> | ||||||
|  |  | ||||||
|  | 			{/* Left side - Info Panel (hidden on mobile) */} | ||||||
|  | 			<div className="hidden lg:flex lg:w-1/2 xl:w-3/5 relative z-10"> | ||||||
|  | 				<div className="flex flex-col justify-between text-white p-12 h-full w-full"> | ||||||
|  | 					<div className="flex-1 flex flex-col justify-center items-start max-w-xl mx-auto"> | ||||||
|  | 						<div className="space-y-6"> | ||||||
| 							<div> | 							<div> | ||||||
| 					<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-primary-100"> | 								<img | ||||||
| 						<Lock size={24} color="#2563eb" strokeWidth={2} /> | 									src="/assets/logo_dark.png" | ||||||
|  | 									alt="PatchMon" | ||||||
|  | 									className="h-16 mb-4" | ||||||
|  | 								/> | ||||||
|  | 								<p className="text-sm text-blue-200 font-medium tracking-wide uppercase"> | ||||||
|  | 									Linux Patch Monitoring | ||||||
|  | 								</p> | ||||||
| 							</div> | 							</div> | ||||||
| 					<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900"> |  | ||||||
|  | 							{latestRelease ? ( | ||||||
|  | 								<div className="space-y-4 bg-black/20 backdrop-blur-sm rounded-lg p-6 border border-white/10"> | ||||||
|  | 									<div className="flex items-center gap-3"> | ||||||
|  | 										<div className="flex items-center gap-2"> | ||||||
|  | 											<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse" /> | ||||||
|  | 											<span className="text-green-300 text-sm font-semibold"> | ||||||
|  | 												Latest Release | ||||||
|  | 											</span> | ||||||
|  | 										</div> | ||||||
|  | 										<span className="text-2xl font-bold text-white"> | ||||||
|  | 											{latestRelease.version} | ||||||
|  | 										</span> | ||||||
|  | 									</div> | ||||||
|  |  | ||||||
|  | 									{latestRelease.name && ( | ||||||
|  | 										<h3 className="text-lg font-semibold text-white"> | ||||||
|  | 											{latestRelease.name} | ||||||
|  | 										</h3> | ||||||
|  | 									)} | ||||||
|  |  | ||||||
|  | 									<div className="flex items-center gap-2 text-sm text-gray-300"> | ||||||
|  | 										<svg | ||||||
|  | 											className="w-4 h-4" | ||||||
|  | 											fill="none" | ||||||
|  | 											stroke="currentColor" | ||||||
|  | 											viewBox="0 0 24 24" | ||||||
|  | 											aria-label="Release date" | ||||||
|  | 										> | ||||||
|  | 											<title>Release date</title> | ||||||
|  | 											<path | ||||||
|  | 												strokeLinecap="round" | ||||||
|  | 												strokeLinejoin="round" | ||||||
|  | 												strokeWidth={2} | ||||||
|  | 												d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" | ||||||
|  | 											/> | ||||||
|  | 										</svg> | ||||||
|  | 										<span>Released {latestRelease.publishedAt}</span> | ||||||
|  | 									</div> | ||||||
|  |  | ||||||
|  | 									{latestRelease.body && ( | ||||||
|  | 										<p className="text-sm text-gray-300 leading-relaxed line-clamp-3"> | ||||||
|  | 											{latestRelease.body} | ||||||
|  | 										</p> | ||||||
|  | 									)} | ||||||
|  |  | ||||||
|  | 									<a | ||||||
|  | 										href="https://github.com/PatchMon/PatchMon/releases/latest" | ||||||
|  | 										target="_blank" | ||||||
|  | 										rel="noopener noreferrer" | ||||||
|  | 										className="inline-flex items-center gap-2 text-sm text-blue-300 hover:text-blue-200 transition-colors font-medium" | ||||||
|  | 									> | ||||||
|  | 										View Release Notes | ||||||
|  | 										<svg | ||||||
|  | 											className="w-4 h-4" | ||||||
|  | 											fill="none" | ||||||
|  | 											stroke="currentColor" | ||||||
|  | 											viewBox="0 0 24 24" | ||||||
|  | 											aria-label="External link" | ||||||
|  | 										> | ||||||
|  | 											<title>External link</title> | ||||||
|  | 											<path | ||||||
|  | 												strokeLinecap="round" | ||||||
|  | 												strokeLinejoin="round" | ||||||
|  | 												strokeWidth={2} | ||||||
|  | 												d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" | ||||||
|  | 											/> | ||||||
|  | 										</svg> | ||||||
|  | 									</a> | ||||||
|  | 								</div> | ||||||
|  | 							) : ( | ||||||
|  | 								<div className="space-y-4 bg-black/20 backdrop-blur-sm rounded-lg p-6 border border-white/10"> | ||||||
|  | 									<div className="animate-pulse space-y-3"> | ||||||
|  | 										<div className="h-6 bg-white/20 rounded w-3/4" /> | ||||||
|  | 										<div className="h-4 bg-white/20 rounded w-1/2" /> | ||||||
|  | 										<div className="h-4 bg-white/20 rounded w-full" /> | ||||||
|  | 									</div> | ||||||
|  | 								</div> | ||||||
|  | 							)} | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  |  | ||||||
|  | 					{/* Social Links Footer */} | ||||||
|  | 					<div className="max-w-xl mx-auto w-full"> | ||||||
|  | 						<div className="border-t border-white/10 pt-6"> | ||||||
|  | 							<p className="text-sm text-gray-400 mb-4">Connect with us</p> | ||||||
|  | 							<div className="flex flex-wrap items-center gap-2"> | ||||||
|  | 								{/* GitHub */} | ||||||
|  | 								<a | ||||||
|  | 									href="https://github.com/PatchMon/PatchMon" | ||||||
|  | 									target="_blank" | ||||||
|  | 									rel="noopener noreferrer" | ||||||
|  | 									className="flex items-center justify-center gap-1.5 px-3 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10" | ||||||
|  | 									title="GitHub Repository" | ||||||
|  | 								> | ||||||
|  | 									<Github className="h-5 w-5 text-white" /> | ||||||
|  | 									{githubStars !== null && ( | ||||||
|  | 										<div className="flex items-center gap-1"> | ||||||
|  | 											<Star className="h-3.5 w-3.5 fill-current text-yellow-400" /> | ||||||
|  | 											<span className="text-sm font-medium text-white"> | ||||||
|  | 												{githubStars} | ||||||
|  | 											</span> | ||||||
|  | 										</div> | ||||||
|  | 									)} | ||||||
|  | 								</a> | ||||||
|  |  | ||||||
|  | 								{/* Roadmap */} | ||||||
|  | 								<a | ||||||
|  | 									href="https://github.com/orgs/PatchMon/projects/2/views/1" | ||||||
|  | 									target="_blank" | ||||||
|  | 									rel="noopener noreferrer" | ||||||
|  | 									className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10" | ||||||
|  | 									title="Roadmap" | ||||||
|  | 								> | ||||||
|  | 									<Route className="h-5 w-5 text-white" /> | ||||||
|  | 								</a> | ||||||
|  |  | ||||||
|  | 								{/* Docs */} | ||||||
|  | 								<a | ||||||
|  | 									href="https://docs.patchmon.net" | ||||||
|  | 									target="_blank" | ||||||
|  | 									rel="noopener noreferrer" | ||||||
|  | 									className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10" | ||||||
|  | 									title="Documentation" | ||||||
|  | 								> | ||||||
|  | 									<BookOpen className="h-5 w-5 text-white" /> | ||||||
|  | 								</a> | ||||||
|  |  | ||||||
|  | 								{/* Discord */} | ||||||
|  | 								<a | ||||||
|  | 									href="https://patchmon.net/discord" | ||||||
|  | 									target="_blank" | ||||||
|  | 									rel="noopener noreferrer" | ||||||
|  | 									className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10" | ||||||
|  | 									title="Discord Community" | ||||||
|  | 								> | ||||||
|  | 									<DiscordIcon className="h-5 w-5 text-white" /> | ||||||
|  | 								</a> | ||||||
|  |  | ||||||
|  | 								{/* Email */} | ||||||
|  | 								<a | ||||||
|  | 									href="mailto:support@patchmon.net" | ||||||
|  | 									className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10" | ||||||
|  | 									title="Email Support" | ||||||
|  | 								> | ||||||
|  | 									<Mail className="h-5 w-5 text-white" /> | ||||||
|  | 								</a> | ||||||
|  |  | ||||||
|  | 								{/* YouTube */} | ||||||
|  | 								<a | ||||||
|  | 									href="https://youtube.com/@patchmonTV" | ||||||
|  | 									target="_blank" | ||||||
|  | 									rel="noopener noreferrer" | ||||||
|  | 									className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10" | ||||||
|  | 									title="YouTube Channel" | ||||||
|  | 								> | ||||||
|  | 									<FaYoutube className="h-5 w-5 text-white" /> | ||||||
|  | 								</a> | ||||||
|  |  | ||||||
|  | 								{/* Reddit */} | ||||||
|  | 								<a | ||||||
|  | 									href="https://www.reddit.com/r/patchmon" | ||||||
|  | 									target="_blank" | ||||||
|  | 									rel="noopener noreferrer" | ||||||
|  | 									className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10" | ||||||
|  | 									title="Reddit Community" | ||||||
|  | 								> | ||||||
|  | 									<FaReddit className="h-5 w-5 text-white" /> | ||||||
|  | 								</a> | ||||||
|  |  | ||||||
|  | 								{/* Website */} | ||||||
|  | 								<a | ||||||
|  | 									href="https://patchmon.net" | ||||||
|  | 									target="_blank" | ||||||
|  | 									rel="noopener noreferrer" | ||||||
|  | 									className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10" | ||||||
|  | 									title="Visit patchmon.net" | ||||||
|  | 								> | ||||||
|  | 									<Globe className="h-5 w-5 text-white" /> | ||||||
|  | 								</a> | ||||||
|  | 							</div> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  | 			</div> | ||||||
|  |  | ||||||
|  | 			{/* Right side - Login Form */} | ||||||
|  | 			<div className="flex-1 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 relative z-10"> | ||||||
|  | 				<div className="max-w-md w-full space-y-8 bg-white dark:bg-secondary-900 rounded-2xl shadow-2xl p-8 lg:p-10"> | ||||||
|  | 					<div> | ||||||
|  | 						<div className="mx-auto h-16 w-16 flex items-center justify-center"> | ||||||
|  | 							<img | ||||||
|  | 								src="/assets/favicon.svg" | ||||||
|  | 								alt="PatchMon Logo" | ||||||
|  | 								className="h-16 w-16" | ||||||
|  | 							/> | ||||||
|  | 						</div> | ||||||
|  | 						<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900 dark:text-secondary-100"> | ||||||
| 							{isSignupMode ? "Create PatchMon Account" : "Sign in to PatchMon"} | 							{isSignupMode ? "Create PatchMon Account" : "Sign in to PatchMon"} | ||||||
| 						</h2> | 						</h2> | ||||||
| 					<p className="mt-2 text-center text-sm text-secondary-600"> | 						<p className="mt-2 text-center text-sm text-secondary-600 dark:text-secondary-400"> | ||||||
| 							Monitor and manage your Linux package updates | 							Monitor and manage your Linux package updates | ||||||
| 						</p> | 						</p> | ||||||
| 					</div> | 					</div> | ||||||
| @@ -262,7 +672,7 @@ const Login = () => { | |||||||
| 								<div> | 								<div> | ||||||
| 									<label | 									<label | ||||||
| 										htmlFor={usernameId} | 										htmlFor={usernameId} | ||||||
| 									className="block text-sm font-medium text-secondary-700" | 										className="block text-sm font-medium text-secondary-900 dark:text-secondary-100" | ||||||
| 									> | 									> | ||||||
| 										{isSignupMode ? "Username" : "Username or Email"} | 										{isSignupMode ? "Username" : "Username or Email"} | ||||||
| 									</label> | 									</label> | ||||||
| @@ -293,7 +703,7 @@ const Login = () => { | |||||||
| 											<div> | 											<div> | ||||||
| 												<label | 												<label | ||||||
| 													htmlFor={firstNameId} | 													htmlFor={firstNameId} | ||||||
| 												className="block text-sm font-medium text-secondary-700" | 													className="block text-sm font-medium text-secondary-900 dark:text-secondary-100" | ||||||
| 												> | 												> | ||||||
| 													First Name | 													First Name | ||||||
| 												</label> | 												</label> | ||||||
| @@ -316,7 +726,7 @@ const Login = () => { | |||||||
| 											<div> | 											<div> | ||||||
| 												<label | 												<label | ||||||
| 													htmlFor={lastNameId} | 													htmlFor={lastNameId} | ||||||
| 												className="block text-sm font-medium text-secondary-700" | 													className="block text-sm font-medium text-secondary-900 dark:text-secondary-100" | ||||||
| 												> | 												> | ||||||
| 													Last Name | 													Last Name | ||||||
| 												</label> | 												</label> | ||||||
| @@ -340,7 +750,7 @@ const Login = () => { | |||||||
| 										<div> | 										<div> | ||||||
| 											<label | 											<label | ||||||
| 												htmlFor={emailId} | 												htmlFor={emailId} | ||||||
| 											className="block text-sm font-medium text-secondary-700" | 												className="block text-sm font-medium text-secondary-900 dark:text-secondary-100" | ||||||
| 											> | 											> | ||||||
| 												Email | 												Email | ||||||
| 											</label> | 											</label> | ||||||
| @@ -366,7 +776,7 @@ const Login = () => { | |||||||
| 								<div> | 								<div> | ||||||
| 									<label | 									<label | ||||||
| 										htmlFor={passwordId} | 										htmlFor={passwordId} | ||||||
| 									className="block text-sm font-medium text-secondary-700" | 										className="block text-sm font-medium text-secondary-900 dark:text-secondary-100" | ||||||
| 									> | 									> | ||||||
| 										Password | 										Password | ||||||
| 									</label> | 									</label> | ||||||
| @@ -433,14 +843,14 @@ const Login = () => { | |||||||
|  |  | ||||||
| 							{signupEnabled && ( | 							{signupEnabled && ( | ||||||
| 								<div className="text-center"> | 								<div className="text-center"> | ||||||
| 								<p className="text-sm text-secondary-600"> | 									<p className="text-sm text-secondary-700 dark:text-secondary-300"> | ||||||
| 										{isSignupMode | 										{isSignupMode | ||||||
| 											? "Already have an account?" | 											? "Already have an account?" | ||||||
| 											: "Don't have an account?"}{" "} | 											: "Don't have an account?"}{" "} | ||||||
| 										<button | 										<button | ||||||
| 											type="button" | 											type="button" | ||||||
| 											onClick={toggleMode} | 											onClick={toggleMode} | ||||||
| 										className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline" | 											className="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300 focus:outline-none focus:underline" | ||||||
| 										> | 										> | ||||||
| 											{isSignupMode ? "Sign in" : "Sign up"} | 											{isSignupMode ? "Sign in" : "Sign up"} | ||||||
| 										</button> | 										</button> | ||||||
| @@ -451,13 +861,17 @@ const Login = () => { | |||||||
| 					) : ( | 					) : ( | ||||||
| 						<form className="mt-8 space-y-6" onSubmit={handleTfaSubmit}> | 						<form className="mt-8 space-y-6" onSubmit={handleTfaSubmit}> | ||||||
| 							<div className="text-center"> | 							<div className="text-center"> | ||||||
| 							<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100"> | 								<div className="mx-auto h-16 w-16 flex items-center justify-center"> | ||||||
| 								<Smartphone size={24} color="#2563eb" strokeWidth={2} /> | 									<img | ||||||
|  | 										src="/assets/favicon.svg" | ||||||
|  | 										alt="PatchMon Logo" | ||||||
|  | 										className="h-16 w-16" | ||||||
|  | 									/> | ||||||
| 								</div> | 								</div> | ||||||
| 							<h3 className="mt-4 text-lg font-medium text-secondary-900"> | 								<h3 className="mt-4 text-lg font-medium text-secondary-900 dark:text-secondary-100"> | ||||||
| 									Two-Factor Authentication | 									Two-Factor Authentication | ||||||
| 								</h3> | 								</h3> | ||||||
| 							<p className="mt-2 text-sm text-secondary-600"> | 								<p className="mt-2 text-sm text-secondary-600 dark:text-secondary-400"> | ||||||
| 									Enter the 6-digit code from your authenticator app | 									Enter the 6-digit code from your authenticator app | ||||||
| 								</p> | 								</p> | ||||||
| 							</div> | 							</div> | ||||||
| @@ -465,7 +879,7 @@ const Login = () => { | |||||||
| 							<div> | 							<div> | ||||||
| 								<label | 								<label | ||||||
| 									htmlFor={tokenId} | 									htmlFor={tokenId} | ||||||
| 								className="block text-sm font-medium text-secondary-700" | 									className="block text-sm font-medium text-secondary-900 dark:text-secondary-100" | ||||||
| 								> | 								> | ||||||
| 									Verification Code | 									Verification Code | ||||||
| 								</label> | 								</label> | ||||||
| @@ -495,7 +909,7 @@ const Login = () => { | |||||||
| 								/> | 								/> | ||||||
| 								<label | 								<label | ||||||
| 									htmlFor={rememberMeId} | 									htmlFor={rememberMeId} | ||||||
| 								className="ml-2 block text-sm text-secondary-700" | 									className="ml-2 block text-sm text-secondary-900 dark:text-secondary-200" | ||||||
| 								> | 								> | ||||||
| 									Remember me on this computer (skip TFA for 30 days) | 									Remember me on this computer (skip TFA for 30 days) | ||||||
| 								</label> | 								</label> | ||||||
| @@ -531,15 +945,19 @@ const Login = () => { | |||||||
| 								<button | 								<button | ||||||
| 									type="button" | 									type="button" | ||||||
| 									onClick={handleBackToLogin} | 									onClick={handleBackToLogin} | ||||||
| 								className="group relative w-full flex justify-center py-2 px-4 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 items-center gap-2" | 									className="group relative w-full flex justify-center py-2 px-4 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-800 hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 items-center gap-2" | ||||||
| 								> | 								> | ||||||
| 								<ArrowLeft size={16} color="#475569" strokeWidth={2} /> | 									<ArrowLeft | ||||||
|  | 										size={16} | ||||||
|  | 										className="text-secondary-700 dark:text-secondary-200" | ||||||
|  | 										strokeWidth={2} | ||||||
|  | 									/> | ||||||
| 									Back to Login | 									Back to Login | ||||||
| 								</button> | 								</button> | ||||||
| 							</div> | 							</div> | ||||||
|  |  | ||||||
| 							<div className="text-center"> | 							<div className="text-center"> | ||||||
| 							<p className="text-sm text-secondary-600"> | 								<p className="text-sm text-secondary-600 dark:text-secondary-400"> | ||||||
| 									Don't have access to your authenticator? Use a backup code. | 									Don't have access to your authenticator? Use a backup code. | ||||||
| 								</p> | 								</p> | ||||||
| 							</div> | 							</div> | ||||||
| @@ -547,6 +965,7 @@ const Login = () => { | |||||||
| 					)} | 					)} | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
|  | 		</div> | ||||||
| 	); | 	); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										399
									
								
								frontend/src/pages/settings/SettingsMetrics.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										399
									
								
								frontend/src/pages/settings/SettingsMetrics.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,399 @@ | |||||||
|  | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | ||||||
|  | import { | ||||||
|  | 	AlertCircle, | ||||||
|  | 	BarChart3, | ||||||
|  | 	CheckCircle, | ||||||
|  | 	Eye, | ||||||
|  | 	EyeOff, | ||||||
|  | 	Globe, | ||||||
|  | 	Info, | ||||||
|  | 	RefreshCw, | ||||||
|  | 	Send, | ||||||
|  | 	Shield, | ||||||
|  | } from "lucide-react"; | ||||||
|  | import { useState } from "react"; | ||||||
|  | import SettingsLayout from "../../components/SettingsLayout"; | ||||||
|  |  | ||||||
|  | // API functions - will be added to utils/api.js | ||||||
|  | const metricsAPI = { | ||||||
|  | 	getSettings: () => | ||||||
|  | 		fetch("/api/v1/metrics", { | ||||||
|  | 			headers: { | ||||||
|  | 				Authorization: `Bearer ${localStorage.getItem("token")}`, | ||||||
|  | 			}, | ||||||
|  | 		}).then((res) => res.json()), | ||||||
|  | 	updateSettings: (data) => | ||||||
|  | 		fetch("/api/v1/metrics", { | ||||||
|  | 			method: "PUT", | ||||||
|  | 			headers: { | ||||||
|  | 				"Content-Type": "application/json", | ||||||
|  | 				Authorization: `Bearer ${localStorage.getItem("token")}`, | ||||||
|  | 			}, | ||||||
|  | 			body: JSON.stringify(data), | ||||||
|  | 		}).then((res) => res.json()), | ||||||
|  | 	regenerateId: () => | ||||||
|  | 		fetch("/api/v1/metrics/regenerate-id", { | ||||||
|  | 			method: "POST", | ||||||
|  | 			headers: { | ||||||
|  | 				Authorization: `Bearer ${localStorage.getItem("token")}`, | ||||||
|  | 			}, | ||||||
|  | 		}).then((res) => res.json()), | ||||||
|  | 	sendNow: () => | ||||||
|  | 		fetch("/api/v1/metrics/send-now", { | ||||||
|  | 			method: "POST", | ||||||
|  | 			headers: { | ||||||
|  | 				Authorization: `Bearer ${localStorage.getItem("token")}`, | ||||||
|  | 			}, | ||||||
|  | 		}).then((res) => res.json()), | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const SettingsMetrics = () => { | ||||||
|  | 	const queryClient = useQueryClient(); | ||||||
|  | 	const [showFullId, setShowFullId] = useState(false); | ||||||
|  |  | ||||||
|  | 	// Fetch metrics settings | ||||||
|  | 	const { | ||||||
|  | 		data: metricsSettings, | ||||||
|  | 		isLoading, | ||||||
|  | 		error, | ||||||
|  | 	} = useQuery({ | ||||||
|  | 		queryKey: ["metrics-settings"], | ||||||
|  | 		queryFn: () => metricsAPI.getSettings(), | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	// Toggle metrics mutation | ||||||
|  | 	const toggleMetricsMutation = useMutation({ | ||||||
|  | 		mutationFn: (enabled) => | ||||||
|  | 			metricsAPI.updateSettings({ metrics_enabled: enabled }), | ||||||
|  | 		onSuccess: () => { | ||||||
|  | 			queryClient.invalidateQueries(["metrics-settings"]); | ||||||
|  | 		}, | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	// Regenerate ID mutation | ||||||
|  | 	const regenerateIdMutation = useMutation({ | ||||||
|  | 		mutationFn: () => metricsAPI.regenerateId(), | ||||||
|  | 		onSuccess: () => { | ||||||
|  | 			queryClient.invalidateQueries(["metrics-settings"]); | ||||||
|  | 		}, | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	// Send now mutation | ||||||
|  | 	const sendNowMutation = useMutation({ | ||||||
|  | 		mutationFn: () => metricsAPI.sendNow(), | ||||||
|  | 		onSuccess: () => { | ||||||
|  | 			queryClient.invalidateQueries(["metrics-settings"]); | ||||||
|  | 		}, | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	if (isLoading) { | ||||||
|  | 		return ( | ||||||
|  | 			<SettingsLayout> | ||||||
|  | 				<div className="flex items-center justify-center h-64"> | ||||||
|  | 					<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div> | ||||||
|  | 				</div> | ||||||
|  | 			</SettingsLayout> | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if (error) { | ||||||
|  | 		return ( | ||||||
|  | 			<SettingsLayout> | ||||||
|  | 				<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4"> | ||||||
|  | 					<div className="flex"> | ||||||
|  | 						<AlertCircle 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"> | ||||||
|  | 								Error loading metrics settings | ||||||
|  | 							</h3> | ||||||
|  | 							<p className="mt-1 text-sm text-red-700 dark:text-red-300"> | ||||||
|  | 								{error.message || "Failed to load settings"} | ||||||
|  | 							</p> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  | 			</SettingsLayout> | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	const maskId = (id) => { | ||||||
|  | 		if (!id) return ""; | ||||||
|  | 		if (showFullId) return id; | ||||||
|  | 		return `${id.substring(0, 8)}...${id.substring(id.length - 8)}`; | ||||||
|  | 	}; | ||||||
|  |  | ||||||
|  | 	return ( | ||||||
|  | 		<SettingsLayout> | ||||||
|  | 			<div className="space-y-6"> | ||||||
|  | 				{/* Header */} | ||||||
|  | 				<div className="flex items-center mb-6"> | ||||||
|  | 					<BarChart3 className="h-6 w-6 text-primary-600 mr-3" /> | ||||||
|  | 					<div> | ||||||
|  | 						<h2 className="text-xl font-semibold text-secondary-900 dark:text-white"> | ||||||
|  | 							Anonymous Metrics & Telemetry | ||||||
|  | 						</h2> | ||||||
|  | 						<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1"> | ||||||
|  | 							Help us understand PatchMon's global usage (100% anonymous) | ||||||
|  | 						</p> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  |  | ||||||
|  | 				{/* Privacy Information */} | ||||||
|  | 				<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg p-6"> | ||||||
|  | 					<div className="flex"> | ||||||
|  | 						<Shield className="h-6 w-6 text-blue-600 dark:text-blue-400 flex-shrink-0" /> | ||||||
|  | 						<div className="ml-4 flex-1"> | ||||||
|  | 							<h3 className="text-base font-semibold text-blue-900 dark:text-blue-100 mb-3"> | ||||||
|  | 								Your Privacy Matters | ||||||
|  | 							</h3> | ||||||
|  | 							<div className="text-sm text-blue-800 dark:text-blue-200 space-y-2"> | ||||||
|  | 								<p className="flex items-start"> | ||||||
|  | 									<CheckCircle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" /> | ||||||
|  | 									<span> | ||||||
|  | 										<strong>We do NOT collect:</strong> IP addresses, hostnames, | ||||||
|  | 										system details, or any personally identifiable information | ||||||
|  | 									</span> | ||||||
|  | 								</p> | ||||||
|  | 								<p className="flex items-start"> | ||||||
|  | 									<CheckCircle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" /> | ||||||
|  | 									<span> | ||||||
|  | 										<strong>We ONLY collect:</strong> An anonymous UUID (for | ||||||
|  | 										deduplication) and the number of hosts you're monitoring | ||||||
|  | 									</span> | ||||||
|  | 								</p> | ||||||
|  | 								<p className="flex items-start"> | ||||||
|  | 									<CheckCircle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" /> | ||||||
|  | 									<span> | ||||||
|  | 										<strong>Purpose:</strong> Display a live counter on our | ||||||
|  | 										website showing global PatchMon adoption | ||||||
|  | 									</span> | ||||||
|  | 								</p> | ||||||
|  | 								<p className="flex items-start"> | ||||||
|  | 									<Globe className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" /> | ||||||
|  | 									<span> | ||||||
|  | 										<strong>Open Source:</strong> All code is public and | ||||||
|  | 										auditable on GitHub | ||||||
|  | 									</span> | ||||||
|  | 								</p> | ||||||
|  | 							</div> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  |  | ||||||
|  | 				{/* Metrics Toggle */} | ||||||
|  | 				<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6"> | ||||||
|  | 					<div className="flex items-start justify-between"> | ||||||
|  | 						<div className="flex-1"> | ||||||
|  | 							<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2"> | ||||||
|  | 								Enable Anonymous Metrics | ||||||
|  | 							</h3> | ||||||
|  | 							<p className="text-sm text-secondary-600 dark:text-secondary-400"> | ||||||
|  | 								Share anonymous usage statistics to help us showcase PatchMon's | ||||||
|  | 								global adoption. Data is sent automatically every 24 hours. | ||||||
|  | 							</p> | ||||||
|  | 						</div> | ||||||
|  | 						<button | ||||||
|  | 							type="button" | ||||||
|  | 							onClick={() => | ||||||
|  | 								toggleMetricsMutation.mutate(!metricsSettings?.metrics_enabled) | ||||||
|  | 							} | ||||||
|  | 							disabled={toggleMetricsMutation.isPending} | ||||||
|  | 							className={`ml-4 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${ | ||||||
|  | 								metricsSettings?.metrics_enabled | ||||||
|  | 									? "bg-primary-600" | ||||||
|  | 									: "bg-secondary-200 dark:bg-secondary-700" | ||||||
|  | 							} ${toggleMetricsMutation.isPending ? "opacity-50" : ""}`} | ||||||
|  | 						> | ||||||
|  | 							<span | ||||||
|  | 								className={`inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${ | ||||||
|  | 									metricsSettings?.metrics_enabled | ||||||
|  | 										? "translate-x-5" | ||||||
|  | 										: "translate-x-0" | ||||||
|  | 								}`} | ||||||
|  | 							/> | ||||||
|  | 						</button> | ||||||
|  | 					</div> | ||||||
|  |  | ||||||
|  | 					{/* Status */} | ||||||
|  | 					<div className="mt-4 pt-4 border-t border-secondary-200 dark:border-secondary-700"> | ||||||
|  | 						<div className="flex items-center text-sm"> | ||||||
|  | 							{metricsSettings?.metrics_enabled ? ( | ||||||
|  | 								<> | ||||||
|  | 									<CheckCircle className="h-4 w-4 text-green-500 mr-2" /> | ||||||
|  | 									<span className="text-green-700 dark:text-green-400"> | ||||||
|  | 										Metrics enabled - Thank you for supporting PatchMon! | ||||||
|  | 									</span> | ||||||
|  | 								</> | ||||||
|  | 							) : ( | ||||||
|  | 								<> | ||||||
|  | 									<EyeOff className="h-4 w-4 text-secondary-500 mr-2" /> | ||||||
|  | 									<span className="text-secondary-600 dark:text-secondary-400"> | ||||||
|  | 										Metrics disabled - No data is being sent | ||||||
|  | 									</span> | ||||||
|  | 								</> | ||||||
|  | 							)} | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  |  | ||||||
|  | 				{/* Anonymous ID Section */} | ||||||
|  | 				<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6"> | ||||||
|  | 					<div className="flex items-start justify-between mb-4"> | ||||||
|  | 						<div> | ||||||
|  | 							<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2"> | ||||||
|  | 								Your Anonymous Instance ID | ||||||
|  | 							</h3> | ||||||
|  | 							<p className="text-sm text-secondary-600 dark:text-secondary-400"> | ||||||
|  | 								This UUID identifies your instance without revealing any | ||||||
|  | 								personal information | ||||||
|  | 							</p> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  |  | ||||||
|  | 					<div className="mt-4 space-y-4"> | ||||||
|  | 						<div className="flex items-center gap-3"> | ||||||
|  | 							<div className="flex-1 bg-secondary-50 dark:bg-secondary-700 rounded-md p-3 font-mono text-sm break-all"> | ||||||
|  | 								{maskId(metricsSettings?.metrics_anonymous_id)} | ||||||
|  | 							</div> | ||||||
|  | 							<button | ||||||
|  | 								type="button" | ||||||
|  | 								onClick={() => setShowFullId(!showFullId)} | ||||||
|  | 								className="p-2 text-secondary-600 dark:text-secondary-400 hover:text-secondary-900 dark:hover:text-white" | ||||||
|  | 								title={showFullId ? "Hide ID" : "Show full ID"} | ||||||
|  | 							> | ||||||
|  | 								{showFullId ? ( | ||||||
|  | 									<EyeOff className="h-5 w-5" /> | ||||||
|  | 								) : ( | ||||||
|  | 									<Eye className="h-5 w-5" /> | ||||||
|  | 								)} | ||||||
|  | 							</button> | ||||||
|  | 						</div> | ||||||
|  |  | ||||||
|  | 						<div className="flex gap-3"> | ||||||
|  | 							<button | ||||||
|  | 								type="button" | ||||||
|  | 								onClick={() => regenerateIdMutation.mutate()} | ||||||
|  | 								disabled={regenerateIdMutation.isPending} | ||||||
|  | 								className="inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50" | ||||||
|  | 							> | ||||||
|  | 								{regenerateIdMutation.isPending ? ( | ||||||
|  | 									<> | ||||||
|  | 										<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-secondary-700 dark:border-secondary-200 mr-2"></div> | ||||||
|  | 										Regenerating... | ||||||
|  | 									</> | ||||||
|  | 								) : ( | ||||||
|  | 									<> | ||||||
|  | 										<RefreshCw className="h-4 w-4 mr-2" /> | ||||||
|  | 										Regenerate ID | ||||||
|  | 									</> | ||||||
|  | 								)} | ||||||
|  | 							</button> | ||||||
|  |  | ||||||
|  | 							<button | ||||||
|  | 								type="button" | ||||||
|  | 								onClick={() => sendNowMutation.mutate()} | ||||||
|  | 								disabled={ | ||||||
|  | 									!metricsSettings?.metrics_enabled || sendNowMutation.isPending | ||||||
|  | 								} | ||||||
|  | 								className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed" | ||||||
|  | 							> | ||||||
|  | 								{sendNowMutation.isPending ? ( | ||||||
|  | 									<> | ||||||
|  | 										<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> | ||||||
|  | 										Sending... | ||||||
|  | 									</> | ||||||
|  | 								) : ( | ||||||
|  | 									<> | ||||||
|  | 										<Send className="h-4 w-4 mr-2" /> | ||||||
|  | 										Send Metrics Now | ||||||
|  | 									</> | ||||||
|  | 								)} | ||||||
|  | 							</button> | ||||||
|  | 						</div> | ||||||
|  |  | ||||||
|  | 						{metricsSettings?.metrics_last_sent && ( | ||||||
|  | 							<p className="text-xs text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 								Last sent:{" "} | ||||||
|  | 								{new Date(metricsSettings.metrics_last_sent).toLocaleString()} | ||||||
|  | 							</p> | ||||||
|  | 						)} | ||||||
|  | 					</div> | ||||||
|  |  | ||||||
|  | 					{/* Success/Error Messages */} | ||||||
|  | 					{regenerateIdMutation.isSuccess && ( | ||||||
|  | 						<div className="mt-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 rounded-md p-3"> | ||||||
|  | 							<div className="flex"> | ||||||
|  | 								<CheckCircle className="h-4 w-4 text-green-400 dark:text-green-300 mt-0.5" /> | ||||||
|  | 								<p className="ml-2 text-sm text-green-700 dark:text-green-300"> | ||||||
|  | 									Anonymous ID regenerated successfully | ||||||
|  | 								</p> | ||||||
|  | 							</div> | ||||||
|  | 						</div> | ||||||
|  | 					)} | ||||||
|  |  | ||||||
|  | 					{sendNowMutation.isSuccess && ( | ||||||
|  | 						<div className="mt-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 rounded-md p-3"> | ||||||
|  | 							<div className="flex"> | ||||||
|  | 								<CheckCircle className="h-4 w-4 text-green-400 dark:text-green-300 mt-0.5" /> | ||||||
|  | 								<div className="ml-2 text-sm text-green-700 dark:text-green-300"> | ||||||
|  | 									<p className="font-medium">Metrics sent successfully!</p> | ||||||
|  | 									{sendNowMutation.data?.data && ( | ||||||
|  | 										<p className="mt-1"> | ||||||
|  | 											Sent: {sendNowMutation.data.data.hostCount} hosts, version{" "} | ||||||
|  | 											{sendNowMutation.data.data.version} | ||||||
|  | 										</p> | ||||||
|  | 									)} | ||||||
|  | 								</div> | ||||||
|  | 							</div> | ||||||
|  | 						</div> | ||||||
|  | 					)} | ||||||
|  |  | ||||||
|  | 					{sendNowMutation.isError && ( | ||||||
|  | 						<div className="mt-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded-md p-3"> | ||||||
|  | 							<div className="flex"> | ||||||
|  | 								<AlertCircle className="h-4 w-4 text-red-400 dark:text-red-300 mt-0.5" /> | ||||||
|  | 								<div className="ml-2 text-sm text-red-700 dark:text-red-300"> | ||||||
|  | 									{sendNowMutation.error?.message || "Failed to send metrics"} | ||||||
|  | 								</div> | ||||||
|  | 							</div> | ||||||
|  | 						</div> | ||||||
|  | 					)} | ||||||
|  | 				</div> | ||||||
|  |  | ||||||
|  | 				{/* Information Panel */} | ||||||
|  | 				<div className="bg-secondary-50 dark:bg-secondary-800/50 border border-secondary-200 dark:border-secondary-700 rounded-lg p-6"> | ||||||
|  | 					<div className="flex"> | ||||||
|  | 						<Info className="h-5 w-5 text-secondary-500 dark:text-secondary-400 flex-shrink-0 mt-0.5" /> | ||||||
|  | 						<div className="ml-3 text-sm text-secondary-700 dark:text-secondary-300"> | ||||||
|  | 							<h4 className="font-medium mb-2">How it works:</h4> | ||||||
|  | 							<ul className="space-y-1 list-disc list-inside"> | ||||||
|  | 								<li> | ||||||
|  | 									Metrics are sent automatically every 24 hours when enabled | ||||||
|  | 								</li> | ||||||
|  | 								<li> | ||||||
|  | 									Only host count and version number are transmitted (no | ||||||
|  | 									sensitive data) | ||||||
|  | 								</li> | ||||||
|  | 								<li>The anonymous UUID prevents duplicate counting</li> | ||||||
|  | 								<li>You can regenerate your ID or opt-out at any time</li> | ||||||
|  | 								<li> | ||||||
|  | 									All collected data is displayed publicly on{" "} | ||||||
|  | 									<a | ||||||
|  | 										href="https://patchmon.net" | ||||||
|  | 										target="_blank" | ||||||
|  | 										rel="noopener noreferrer" | ||||||
|  | 										className="text-primary-600 dark:text-primary-400 hover:underline" | ||||||
|  | 									> | ||||||
|  | 										patchmon.net | ||||||
|  | 									</a> | ||||||
|  | 								</li> | ||||||
|  | 							</ul> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  | 			</div> | ||||||
|  | 		</SettingsLayout> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default SettingsMetrics; | ||||||
| @@ -5,7 +5,7 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || "/api/v1"; | |||||||
| // Create axios instance with default config | // Create axios instance with default config | ||||||
| const api = axios.create({ | const api = axios.create({ | ||||||
| 	baseURL: API_BASE_URL, | 	baseURL: API_BASE_URL, | ||||||
| 	timeout: 10000, | 	timeout: 10000, // 10 seconds | ||||||
| 	headers: { | 	headers: { | ||||||
| 		"Content-Type": "application/json", | 		"Content-Type": "application/json", | ||||||
| 	}, | 	}, | ||||||
| @@ -95,6 +95,7 @@ export const adminHostsAPI = { | |||||||
| 		api.put("/hosts/bulk/groups", { hostIds, groupIds }), | 		api.put("/hosts/bulk/groups", { hostIds, groupIds }), | ||||||
| 	toggleAutoUpdate: (hostId, autoUpdate) => | 	toggleAutoUpdate: (hostId, autoUpdate) => | ||||||
| 		api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }), | 		api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }), | ||||||
|  | 	forceAgentUpdate: (hostId) => api.post(`/hosts/${hostId}/force-agent-update`), | ||||||
| 	updateFriendlyName: (hostId, friendlyName) => | 	updateFriendlyName: (hostId, friendlyName) => | ||||||
| 		api.patch(`/hosts/${hostId}/friendly-name`, { | 		api.patch(`/hosts/${hostId}/friendly-name`, { | ||||||
| 			friendly_name: friendlyName, | 			friendly_name: friendlyName, | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | import { Agent as HttpAgent } from "node:http"; | ||||||
| import react from "@vitejs/plugin-react"; | import react from "@vitejs/plugin-react"; | ||||||
| import { defineConfig } from "vite"; | import { defineConfig } from "vite"; | ||||||
|  |  | ||||||
| @@ -14,6 +15,15 @@ export default defineConfig({ | |||||||
| 				target: `http://${process.env.BACKEND_HOST || "localhost"}:${process.env.BACKEND_PORT || "3001"}`, | 				target: `http://${process.env.BACKEND_HOST || "localhost"}:${process.env.BACKEND_PORT || "3001"}`, | ||||||
| 				changeOrigin: true, | 				changeOrigin: true, | ||||||
| 				secure: false, | 				secure: false, | ||||||
|  | 				// Configure HTTP agent to support more concurrent connections | ||||||
|  | 				// Fixes 1000ms timeout issue when using HTTP (not HTTPS) with multiple hosts | ||||||
|  | 				agent: new HttpAgent({ | ||||||
|  | 					keepAlive: true, | ||||||
|  | 					maxSockets: 50, // Increase from default 6 to handle multiple hosts | ||||||
|  | 					maxFreeSockets: 10, | ||||||
|  | 					timeout: 60000, | ||||||
|  | 					keepAliveMsecs: 1000, | ||||||
|  | 				}), | ||||||
| 				configure: | 				configure: | ||||||
| 					process.env.VITE_ENABLE_LOGGING === "true" | 					process.env.VITE_ENABLE_LOGGING === "true" | ||||||
| 						? (proxy, _options) => { | 						? (proxy, _options) => { | ||||||
|   | |||||||
							
								
								
									
										1785
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1785
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,6 +1,6 @@ | |||||||
| { | { | ||||||
| 	"name": "patchmon", | 	"name": "patchmon", | ||||||
| 	"version": "1.3.0", | 	"version": "1.3.1", | ||||||
| 	"description": "Linux Patch Monitoring System", | 	"description": "Linux Patch Monitoring System", | ||||||
| 	"license": "AGPL-3.0", | 	"license": "AGPL-3.0", | ||||||
| 	"private": true, | 	"private": true, | ||||||
| @@ -25,7 +25,7 @@ | |||||||
| 		"lint:fix": "biome check --write ." | 		"lint:fix": "biome check --write ." | ||||||
| 	}, | 	}, | ||||||
| 	"devDependencies": { | 	"devDependencies": { | ||||||
| 		"@biomejs/biome": "2.2.4", | 		"@biomejs/biome": "^2.3.0", | ||||||
| 		"concurrently": "^8.2.2", | 		"concurrently": "^8.2.2", | ||||||
| 		"lefthook": "^1.13.4" | 		"lefthook": "^1.13.4" | ||||||
| 	}, | 	}, | ||||||
|   | |||||||
							
								
								
									
										715
									
								
								tools/diagnostics.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										715
									
								
								tools/diagnostics.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,715 @@ | |||||||
|  | #!/bin/bash | ||||||
|  | # PatchMon Diagnostics Collection Script | ||||||
|  | # Collects system information, logs, and configuration for troubleshooting | ||||||
|  | # Usage: sudo bash diagnostics.sh [instance-name] | ||||||
|  |  | ||||||
|  | # Note: Not using 'set -e' because we want to continue even if some commands fail | ||||||
|  | set -o pipefail | ||||||
|  |  | ||||||
|  | # Colors for output | ||||||
|  | RED='\033[0;31m' | ||||||
|  | GREEN='\033[0;32m' | ||||||
|  | YELLOW='\033[1;33m' | ||||||
|  | BLUE='\033[0;34m' | ||||||
|  | NC='\033[0m' # No Color | ||||||
|  |  | ||||||
|  | # Print functions | ||||||
|  | print_status() { | ||||||
|  |     echo -e "${GREEN}✅ $1${NC}" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | print_info() { | ||||||
|  |     echo -e "${BLUE}ℹ️  $1${NC}" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | print_error() { | ||||||
|  |     echo -e "${RED}❌ $1${NC}" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | print_warning() { | ||||||
|  |     echo -e "${YELLOW}⚠️  $1${NC}" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | print_success() { | ||||||
|  |     echo -e "${GREEN}🎉 $1${NC}" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | # Check if running as root | ||||||
|  | if [[ $EUID -ne 0 ]]; then | ||||||
|  |     print_error "This script must be run as root" | ||||||
|  |     print_info "Please run: sudo bash $0" | ||||||
|  |     exit 1 | ||||||
|  | fi | ||||||
|  |  | ||||||
|  | # Function to sanitize sensitive information | ||||||
|  | sanitize_sensitive() { | ||||||
|  |     local input="$1" | ||||||
|  |     # Replace passwords, secrets, and tokens with [REDACTED] | ||||||
|  |     echo "$input" | \ | ||||||
|  |         sed -E 's/(PASSWORD|SECRET|TOKEN|KEY|PASS)=[^"]*$/\1=[REDACTED]/gi' | \ | ||||||
|  |         sed -E 's/(PASSWORD|SECRET|TOKEN|KEY|PASS)="[^"]*"/\1="[REDACTED]"/gi' | \ | ||||||
|  |         sed -E 's/(password|secret|token|key|pass)": *"[^"]*"/\1": "[REDACTED]"/gi' | \ | ||||||
|  |         sed -E 's/(>)[a-zA-Z0-9+\/=]{20,}/\1[REDACTED]/g' | \ | ||||||
|  |         sed -E 's|postgresql://([^:]+):([^@]+)@|postgresql://\1:[REDACTED]@|g' | \ | ||||||
|  |         sed -E 's|mysql://([^:]+):([^@]+)@|mysql://\1:[REDACTED]@|g' | \ | ||||||
|  |         sed -E 's|mongodb://([^:]+):([^@]+)@|mongodb://\1:[REDACTED]@|g' | ||||||
|  | } | ||||||
|  |  | ||||||
|  | # Function to detect PatchMon installations | ||||||
|  | detect_installations() { | ||||||
|  |     local installations=() | ||||||
|  |      | ||||||
|  |     if [ ! -d "/opt" ]; then | ||||||
|  |         print_error "/opt directory does not exist" | ||||||
|  |         return 1 | ||||||
|  |     fi | ||||||
|  |      | ||||||
|  |     for dir in /opt/*/; do | ||||||
|  |         # Skip if no directories found | ||||||
|  |         [ -d "$dir" ] || continue | ||||||
|  |          | ||||||
|  |         local dirname=$(basename "$dir") | ||||||
|  |          | ||||||
|  |         # Skip backup directories | ||||||
|  |         if [[ "$dirname" =~ \.backup\. ]]; then | ||||||
|  |             continue | ||||||
|  |         fi | ||||||
|  |          | ||||||
|  |         # Check if it's a PatchMon installation | ||||||
|  |         if [ -f "$dir/backend/package.json" ]; then | ||||||
|  |             if grep -q "patchmon" "$dir/backend/package.json" 2>/dev/null; then | ||||||
|  |                 installations+=("$dirname") | ||||||
|  |             fi | ||||||
|  |         fi | ||||||
|  |     done | ||||||
|  |      | ||||||
|  |     echo "${installations[@]}" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | # Function to select installation | ||||||
|  | select_installation() { | ||||||
|  |     local installations=($(detect_installations)) | ||||||
|  |      | ||||||
|  |     if [ ${#installations[@]} -eq 0 ]; then | ||||||
|  |         print_error "No PatchMon installations found in /opt" >&2 | ||||||
|  |         exit 1 | ||||||
|  |     fi | ||||||
|  |      | ||||||
|  |     if [ -n "$1" ]; then | ||||||
|  |         # Use provided instance name | ||||||
|  |         if [[ " ${installations[@]} " =~ " $1 " ]]; then | ||||||
|  |             echo "$1" | ||||||
|  |             return 0 | ||||||
|  |         else | ||||||
|  |             print_error "Instance '$1' not found" >&2 | ||||||
|  |             exit 1 | ||||||
|  |         fi | ||||||
|  |     fi | ||||||
|  |      | ||||||
|  |     # Send status messages to stderr so they don't contaminate the return value | ||||||
|  |     print_info "Found ${#installations[@]} installation(s):" >&2 | ||||||
|  |     echo "" >&2 | ||||||
|  |      | ||||||
|  |     local i=1 | ||||||
|  |     declare -A install_map | ||||||
|  |     for install in "${installations[@]}"; do | ||||||
|  |         # Get service status | ||||||
|  |         local status="unknown" | ||||||
|  |         if systemctl is-active --quiet "$install" 2>/dev/null; then | ||||||
|  |             status="${GREEN}running${NC}" | ||||||
|  |         elif systemctl is-enabled --quiet "$install" 2>/dev/null; then | ||||||
|  |             status="${RED}stopped${NC}" | ||||||
|  |         fi | ||||||
|  |          | ||||||
|  |         printf "%2d. %-30s (%b)\n" "$i" "$install" "$status" >&2 | ||||||
|  |         install_map[$i]="$install" | ||||||
|  |         i=$((i + 1)) | ||||||
|  |     done | ||||||
|  |      | ||||||
|  |     echo "" >&2 | ||||||
|  |      | ||||||
|  |     # If only one installation, select it automatically | ||||||
|  |     if [ ${#installations[@]} -eq 1 ]; then | ||||||
|  |         print_info "Only one installation found, selecting automatically: ${installations[0]}" >&2 | ||||||
|  |         echo "${installations[0]}" | ||||||
|  |         return 0 | ||||||
|  |     fi | ||||||
|  |      | ||||||
|  |     # Multiple installations - prompt user | ||||||
|  |     printf "${BLUE}Select installation number [1]: ${NC}" >&2 | ||||||
|  |     read -r selection </dev/tty | ||||||
|  |      | ||||||
|  |     selection=${selection:-1} | ||||||
|  |      | ||||||
|  |     if [[ "$selection" =~ ^[0-9]+$ ]] && [ -n "${install_map[$selection]}" ]; then | ||||||
|  |         echo "${install_map[$selection]}" | ||||||
|  |         return 0 | ||||||
|  |     else | ||||||
|  |         print_error "Invalid selection" >&2 | ||||||
|  |         exit 1 | ||||||
|  |     fi | ||||||
|  | } | ||||||
|  |  | ||||||
|  | # Main script | ||||||
|  | main() { | ||||||
|  |     # Capture the directory where script is run from at the very start | ||||||
|  |     ORIGINAL_DIR=$(pwd) | ||||||
|  |      | ||||||
|  |     echo -e "${BLUE}====================================================${NC}" | ||||||
|  |     echo -e "${BLUE}        PatchMon Diagnostics Collection${NC}" | ||||||
|  |     echo -e "${BLUE}====================================================${NC}" | ||||||
|  |     echo "" | ||||||
|  |      | ||||||
|  |     # Select instance | ||||||
|  |     instance_name=$(select_installation "$1") | ||||||
|  |     instance_dir="/opt/$instance_name" | ||||||
|  |      | ||||||
|  |     print_info "Selected instance: $instance_name" | ||||||
|  |     print_info "Directory: $instance_dir" | ||||||
|  |     echo "" | ||||||
|  |      | ||||||
|  |     # Create single diagnostics file in the original directory | ||||||
|  |     timestamp=$(date +%Y%m%d_%H%M%S) | ||||||
|  |     diag_file="${ORIGINAL_DIR}/patchmon_diagnostics_${instance_name}_${timestamp}.txt" | ||||||
|  |      | ||||||
|  |     print_info "Collecting diagnostics to: $diag_file" | ||||||
|  |     echo "" | ||||||
|  |      | ||||||
|  |     # Initialize the diagnostics file with header | ||||||
|  |     cat > "$diag_file" << EOF | ||||||
|  | =================================================== | ||||||
|  | PatchMon Diagnostics Report | ||||||
|  | =================================================== | ||||||
|  | Instance: $instance_name | ||||||
|  | Generated: $(date) | ||||||
|  | Hostname: $(hostname) | ||||||
|  | Generated from: ${ORIGINAL_DIR} | ||||||
|  | =================================================== | ||||||
|  |  | ||||||
|  | EOF | ||||||
|  |      | ||||||
|  |     # ======================================== | ||||||
|  |     # 1. System Information | ||||||
|  |     # ======================================== | ||||||
|  |     print_info "Collecting system information..." | ||||||
|  |      | ||||||
|  |     cat >> "$diag_file" << EOF | ||||||
|  | === System Information === | ||||||
|  | OS: $(cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'"' -f2 || echo "Unknown") | ||||||
|  | Kernel: $(uname -r) | ||||||
|  | Uptime: $(uptime) | ||||||
|  |  | ||||||
|  | === CPU Information === | ||||||
|  | $(lscpu | grep -E "Model name|CPU\(s\)|Thread|Core" || echo "Not available") | ||||||
|  |  | ||||||
|  | === Memory Information === | ||||||
|  | $(free -h) | ||||||
|  |  | ||||||
|  | === Disk Usage === | ||||||
|  | $(df -h | grep -E "Filesystem|/dev/|/opt") | ||||||
|  |  | ||||||
|  | === Network Interfaces === | ||||||
|  | $(ip -br addr) | ||||||
|  |  | ||||||
|  | =================================================== | ||||||
|  | EOF | ||||||
|  |      | ||||||
|  |     # ======================================== | ||||||
|  |     # 2. PatchMon Instance Information | ||||||
|  |     # ======================================== | ||||||
|  |     print_info "Collecting instance information..." | ||||||
|  |      | ||||||
|  |     cat >> "$diag_file" << EOF | ||||||
|  |  | ||||||
|  | === PatchMon Instance Information === | ||||||
|  |  | ||||||
|  | === Directory Structure === | ||||||
|  | $(ls -lah "$instance_dir" 2>/dev/null || echo "Cannot access directory") | ||||||
|  |  | ||||||
|  | === Backend Package Info === | ||||||
|  | $(cat "$instance_dir/backend/package.json" 2>/dev/null | grep -E "name|version" || echo "Not found") | ||||||
|  |  | ||||||
|  | === Frontend Package Info === | ||||||
|  | $(cat "$instance_dir/frontend/package.json" 2>/dev/null | grep -E "name|version" || echo "Not found") | ||||||
|  |  | ||||||
|  | === Deployment Info === | ||||||
|  | $(cat "$instance_dir/deployment-info.txt" 2>/dev/null || echo "No deployment-info.txt found") | ||||||
|  |  | ||||||
|  | =================================================== | ||||||
|  | EOF | ||||||
|  |      | ||||||
|  |     # ======================================== | ||||||
|  |     # 3. Environment Configuration (Sanitized) | ||||||
|  |     # ======================================== | ||||||
|  |     print_info "Collecting environment configuration (sanitized)..." | ||||||
|  |      | ||||||
|  |     echo "" >> "$diag_file" | ||||||
|  |     echo "=== Backend Environment Configuration (Sanitized) ===" >> "$diag_file" | ||||||
|  |     if [ -f "$instance_dir/backend/.env" ]; then | ||||||
|  |         sanitize_sensitive "$(cat "$instance_dir/backend/.env")" >> "$diag_file" | ||||||
|  |     else | ||||||
|  |         echo "Backend .env file not found" >> "$diag_file" | ||||||
|  |     fi | ||||||
|  |     echo "" >> "$diag_file" | ||||||
|  |      | ||||||
|  |     # ======================================== | ||||||
|  |     # 4. Service Status and Configuration | ||||||
|  |     # ======================================== | ||||||
|  |     print_info "Collecting service information..." | ||||||
|  |      | ||||||
|  |     cat >> "$diag_file" << EOF | ||||||
|  |  | ||||||
|  | === Service Status and Configuration === | ||||||
|  |  | ||||||
|  | === Service Status === | ||||||
|  | $(systemctl status "$instance_name" 2>/dev/null || echo "Service not found") | ||||||
|  |  | ||||||
|  | === Service File === | ||||||
|  | $(cat "/etc/systemd/system/${instance_name}.service" 2>/dev/null || echo "Service file not found") | ||||||
|  |  | ||||||
|  | === Service is-enabled === | ||||||
|  | $(systemctl is-enabled "$instance_name" 2>/dev/null || echo "unknown") | ||||||
|  |  | ||||||
|  | === Service is-active === | ||||||
|  | $(systemctl is-active "$instance_name" 2>/dev/null || echo "unknown") | ||||||
|  |  | ||||||
|  | =================================================== | ||||||
|  | EOF | ||||||
|  |      | ||||||
|  |     # ======================================== | ||||||
|  |     # 5. Service Logs | ||||||
|  |     # ======================================== | ||||||
|  |     print_info "Collecting service logs..." | ||||||
|  |      | ||||||
|  |     echo "" >> "$diag_file" | ||||||
|  |     echo "=== Service Logs (last 500 lines) ===" >> "$diag_file" | ||||||
|  |     journalctl -u "$instance_name" -n 500 --no-pager >> "$diag_file" 2>&1 || \ | ||||||
|  |         echo "Could not retrieve service logs" >> "$diag_file" | ||||||
|  |     echo "" >> "$diag_file" | ||||||
|  |      | ||||||
|  |     # ======================================== | ||||||
|  |     # 6. Nginx Configuration | ||||||
|  |     # ======================================== | ||||||
|  |     print_info "Collecting nginx configuration..." | ||||||
|  |      | ||||||
|  |     cat >> "$diag_file" << EOF | ||||||
|  |  | ||||||
|  | === Nginx Configuration === | ||||||
|  |  | ||||||
|  | === Nginx Status === | ||||||
|  | $(systemctl status nginx 2>/dev/null | head -20 || echo "Nginx not found") | ||||||
|  |  | ||||||
|  | === Site Configuration === | ||||||
|  | $(cat "/etc/nginx/sites-available/$instance_name" 2>/dev/null || echo "Nginx config not found") | ||||||
|  |  | ||||||
|  | === Nginx Error Log (last 100 lines) === | ||||||
|  | $(tail -100 /var/log/nginx/error.log 2>/dev/null || echo "Error log not accessible") | ||||||
|  |  | ||||||
|  | === Nginx Access Log (last 50 lines) === | ||||||
|  | $(tail -50 /var/log/nginx/access.log 2>/dev/null || echo "Access log not accessible") | ||||||
|  |  | ||||||
|  | === Nginx Test === | ||||||
|  | $(nginx -t 2>&1 || echo "Nginx test failed") | ||||||
|  |  | ||||||
|  | =================================================== | ||||||
|  | EOF | ||||||
|  |      | ||||||
|  |     # ======================================== | ||||||
|  |     # 7. Database Connection Test | ||||||
|  |     # ======================================== | ||||||
|  |     print_info "Testing database connection..." | ||||||
|  |      | ||||||
|  |     echo "" >> "$diag_file" | ||||||
|  |     echo "=== Database Information ===" >> "$diag_file" | ||||||
|  |     echo "" >> "$diag_file" | ||||||
|  |      | ||||||
|  |     if [ -f "$instance_dir/backend/.env" ]; then | ||||||
|  |         # Load .env | ||||||
|  |         set -a | ||||||
|  |         source "$instance_dir/backend/.env" | ||||||
|  |         set +a | ||||||
|  |          | ||||||
|  |         # Parse DATABASE_URL | ||||||
|  |         if [ -n "$DATABASE_URL" ]; then | ||||||
|  |             DB_USER=$(echo "$DATABASE_URL" | sed -n 's|postgresql://\([^:]*\):.*|\1|p') | ||||||
|  |             DB_PASS=$(echo "$DATABASE_URL" | sed -n 's|postgresql://[^:]*:\([^@]*\)@.*|\1|p') | ||||||
|  |             DB_HOST=$(echo "$DATABASE_URL" | sed -n 's|.*@\([^:]*\):.*|\1|p') | ||||||
|  |             DB_PORT=$(echo "$DATABASE_URL" | sed -n 's|.*:\([0-9]*\)/.*|\1|p') | ||||||
|  |             DB_NAME=$(echo "$DATABASE_URL" | sed -n 's|.*/\([^?]*\).*|\1|p') | ||||||
|  |              | ||||||
|  |             cat >> "$diag_file" << EOF | ||||||
|  | === Database Connection Details === | ||||||
|  | Host: $DB_HOST | ||||||
|  | Port: $DB_PORT | ||||||
|  | Database: $DB_NAME | ||||||
|  | User: $DB_USER | ||||||
|  |  | ||||||
|  | === PostgreSQL Status === | ||||||
|  | $(systemctl status postgresql 2>/dev/null | head -20 || echo "PostgreSQL status not available") | ||||||
|  |  | ||||||
|  | === Connection Test === | ||||||
|  | EOF | ||||||
|  |              | ||||||
|  |             if PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c "SELECT version();" >> "$diag_file" 2>&1; then | ||||||
|  |                 echo "✅ Database connection: SUCCESSFUL" >> "$diag_file" | ||||||
|  |             else | ||||||
|  |                 echo "❌ Database connection: FAILED" >> "$diag_file" | ||||||
|  |             fi | ||||||
|  |              | ||||||
|  |             echo "" >> "$diag_file" | ||||||
|  |             echo "=== Database Size ===" >> "$diag_file" | ||||||
|  |             PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c " | ||||||
|  |                 SELECT  | ||||||
|  |                     pg_size_pretty(pg_database_size('$DB_NAME')) as database_size; | ||||||
|  |             " >> "$diag_file" 2>&1 || echo "Could not get database size" >> "$diag_file" | ||||||
|  |              | ||||||
|  |             echo "" >> "$diag_file" | ||||||
|  |             echo "=== Table Sizes ===" >> "$diag_file" | ||||||
|  |             PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c " | ||||||
|  |                 SELECT  | ||||||
|  |                     schemaname, | ||||||
|  |                     tablename, | ||||||
|  |                     pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size | ||||||
|  |                 FROM pg_tables | ||||||
|  |                 WHERE schemaname = 'public' | ||||||
|  |                 ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC | ||||||
|  |                 LIMIT 10; | ||||||
|  |             " >> "$diag_file" 2>&1 || echo "Could not get table sizes" >> "$diag_file" | ||||||
|  |              | ||||||
|  |             echo "" >> "$diag_file" | ||||||
|  |             echo "=== Migration Status ===" >> "$diag_file" | ||||||
|  |             cd "$instance_dir/backend" | ||||||
|  |             npx prisma migrate status >> "$diag_file" 2>&1 || echo "Could not get migration status" >> "$diag_file" | ||||||
|  |              | ||||||
|  |             echo "===================================================" >> "$diag_file" | ||||||
|  |         else | ||||||
|  |             echo "DATABASE_URL not found in .env" >> "$diag_file" | ||||||
|  |         fi | ||||||
|  |     else | ||||||
|  |         echo ".env file not found" >> "$diag_file" | ||||||
|  |     fi | ||||||
|  |      | ||||||
|  |     # ======================================== | ||||||
|  |     # 8. Redis Connection Test | ||||||
|  |     # ======================================== | ||||||
|  |     print_info "Testing Redis connection..." | ||||||
|  |      | ||||||
|  |     if [ -f "$instance_dir/backend/.env" ]; then | ||||||
|  |         # Load .env | ||||||
|  |         set -a | ||||||
|  |         source "$instance_dir/backend/.env" | ||||||
|  |         set +a | ||||||
|  |          | ||||||
|  |         cat >> "$diag_file" << EOF | ||||||
|  | =================================================== | ||||||
|  | Redis Information | ||||||
|  | =================================================== | ||||||
|  |  | ||||||
|  | === Redis Connection Details === | ||||||
|  | Host: ${REDIS_HOST:-localhost} | ||||||
|  | Port: ${REDIS_PORT:-6379} | ||||||
|  | User: ${REDIS_USER:-(none)} | ||||||
|  | Database: ${REDIS_DB:-0} | ||||||
|  |  | ||||||
|  | === Redis Status === | ||||||
|  | $(systemctl status redis-server 2>/dev/null | head -20 || echo "Redis status not available") | ||||||
|  |  | ||||||
|  | === Connection Test === | ||||||
|  | EOF | ||||||
|  |          | ||||||
|  |         # Test connection | ||||||
|  |         if [ -n "$REDIS_USER" ] && [ -n "$REDIS_PASSWORD" ]; then | ||||||
|  |             if redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" --user "$REDIS_USER" --pass "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" ping >> "$diag_file" 2>&1; then | ||||||
|  |                 echo "✅ Redis connection (with user): SUCCESSFUL" >> "$diag_file" | ||||||
|  |                  | ||||||
|  |                 echo "" >> "$diag_file" | ||||||
|  |                 echo "=== Redis INFO ===" >> "$diag_file" | ||||||
|  |                 redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" --user "$REDIS_USER" --pass "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" INFO >> "$diag_file" 2>&1 | ||||||
|  |                  | ||||||
|  |                 echo "" >> "$diag_file" | ||||||
|  |                 echo "=== Redis Database Size ===" >> "$diag_file" | ||||||
|  |                 redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" --user "$REDIS_USER" --pass "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" DBSIZE >> "$diag_file" 2>&1 | ||||||
|  |             else | ||||||
|  |                 echo "❌ Redis connection (with user): FAILED" >> "$diag_file" | ||||||
|  |             fi | ||||||
|  |         elif [ -n "$REDIS_PASSWORD" ]; then | ||||||
|  |             if redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" -a "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" ping >> "$diag_file" 2>&1; then | ||||||
|  |                 echo "✅ Redis connection (requirepass): SUCCESSFUL" >> "$diag_file" | ||||||
|  |                  | ||||||
|  |                 echo "" >> "$diag_file" | ||||||
|  |                 echo "=== Redis INFO ===" >> "$diag_file" | ||||||
|  |                 redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" -a "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" INFO >> "$diag_file" 2>&1 | ||||||
|  |                  | ||||||
|  |                 echo "" >> "$diag_file" | ||||||
|  |                 echo "=== Redis Database Size ===" >> "$diag_file" | ||||||
|  |                 redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" -a "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" DBSIZE >> "$diag_file" 2>&1 | ||||||
|  |             else | ||||||
|  |                 echo "❌ Redis connection (requirepass): FAILED" >> "$diag_file" | ||||||
|  |             fi | ||||||
|  |         else | ||||||
|  |             if redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" -n "${REDIS_DB:-0}" ping >> "$diag_file" 2>&1; then | ||||||
|  |                 echo "✅ Redis connection (no auth): SUCCESSFUL" >> "$diag_file" | ||||||
|  |             else | ||||||
|  |                 echo "❌ Redis connection: FAILED" >> "$diag_file" | ||||||
|  |             fi | ||||||
|  |         fi | ||||||
|  |          | ||||||
|  |         echo "" >> "$diag_file" | ||||||
|  |         echo "=== Redis ACL Users ===" >> "$diag_file" | ||||||
|  |         if [ -n "$REDIS_USER" ] && [ -n "$REDIS_PASSWORD" ]; then | ||||||
|  |             redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" --user "$REDIS_USER" --pass "$REDIS_PASSWORD" --no-auth-warning ACL LIST >> "$diag_file" | ||||||
|  |         elif [ -n "$REDIS_PASSWORD" ]; then | ||||||
|  |             redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" -a "$REDIS_PASSWORD" --no-auth-warning ACL LIST >> "$diag_file" | ||||||
|  |         fi | ||||||
|  |          | ||||||
|  |         echo "===================================================" >> "$diag_file" | ||||||
|  |     else | ||||||
|  |         echo ".env file not found" >> "$diag_file" | ||||||
|  |     fi | ||||||
|  |      | ||||||
|  |     # ======================================== | ||||||
|  |     # 9. Network and Port Information | ||||||
|  |     # ======================================== | ||||||
|  |     print_info "Collecting network information..." | ||||||
|  |      | ||||||
|  |     # Get backend port from .env | ||||||
|  |     local backend_port=$(grep '^PORT=' "$instance_dir/backend/.env" 2>/dev/null | cut -d'=' -f2 | tr -d ' ' || echo "3000") | ||||||
|  |      | ||||||
|  |     cat >> "$diag_file" << EOF | ||||||
|  | =================================================== | ||||||
|  | Network and Port Information | ||||||
|  | =================================================== | ||||||
|  |  | ||||||
|  | === Listening Ports === | ||||||
|  | $(ss -tlnp | grep -E "LISTEN|nginx|node|postgres|redis" || netstat -tlnp | grep -E "LISTEN|nginx|node|postgres|redis" || echo "Could not get port information") | ||||||
|  |  | ||||||
|  | === Active Connections === | ||||||
|  | $(ss -tn state established | head -20 || echo "Could not get connection information") | ||||||
|  |  | ||||||
|  | === Backend Port Connections (Port $backend_port) === | ||||||
|  | Total connections to backend: $(ss -tn | grep ":$backend_port" | wc -l || echo "0") | ||||||
|  | $(ss -tn | grep ":$backend_port" | head -10 || echo "No connections found") | ||||||
|  |  | ||||||
|  | === PostgreSQL Connections === | ||||||
|  | EOF | ||||||
|  |  | ||||||
|  |     # Get PostgreSQL connection count | ||||||
|  |     if [ -n "$DB_PASS" ] && [ -n "$DB_USER" ] && [ -n "$DB_NAME" ]; then | ||||||
|  |         PGPASSWORD="$DB_PASS" psql -h "${DB_HOST:-localhost}" -U "$DB_USER" -d "$DB_NAME" -c " | ||||||
|  |             SELECT  | ||||||
|  |                 count(*) as total_connections, | ||||||
|  |                 count(*) FILTER (WHERE state = 'active') as active_connections, | ||||||
|  |                 count(*) FILTER (WHERE state = 'idle') as idle_connections | ||||||
|  |             FROM pg_stat_activity  | ||||||
|  |             WHERE datname = '$DB_NAME'; | ||||||
|  |         " >> "$diag_file" 2>&1 || echo "Could not get PostgreSQL connection stats" >> "$diag_file" | ||||||
|  |          | ||||||
|  |         echo "" >> "$diag_file" | ||||||
|  |         echo "=== PostgreSQL Connection Details ===" >> "$diag_file" | ||||||
|  |         PGPASSWORD="$DB_PASS" psql -h "${DB_HOST:-localhost}" -U "$DB_USER" -d "$DB_NAME" -c " | ||||||
|  |             SELECT  | ||||||
|  |                 pid, | ||||||
|  |                 usename, | ||||||
|  |                 application_name, | ||||||
|  |                 client_addr, | ||||||
|  |                 state, | ||||||
|  |                 query_start, | ||||||
|  |                 state_change | ||||||
|  |             FROM pg_stat_activity  | ||||||
|  |             WHERE datname = '$DB_NAME' | ||||||
|  |             ORDER BY query_start DESC | ||||||
|  |             LIMIT 20; | ||||||
|  |         " >> "$diag_file" 2>&1 || echo "Could not get connection details" >> "$diag_file" | ||||||
|  |     else | ||||||
|  |         echo "Database credentials not available" >> "$diag_file" | ||||||
|  |     fi | ||||||
|  |      | ||||||
|  |     echo "" >> "$diag_file" | ||||||
|  |     echo "=== Redis Connections ===" >> "$diag_file" | ||||||
|  |      | ||||||
|  |     # Get Redis connection count | ||||||
|  |     if [ -n "$REDIS_USER" ] && [ -n "$REDIS_PASSWORD" ]; then | ||||||
|  |         redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" --user "$REDIS_USER" --pass "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" INFO clients >> "$diag_file" 2>&1 || echo "Could not get Redis connection info" >> "$diag_file" | ||||||
|  |     elif [ -n "$REDIS_PASSWORD" ]; then | ||||||
|  |         redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" -a "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" INFO clients >> "$diag_file" 2>&1 || echo "Could not get Redis connection info" >> "$diag_file" | ||||||
|  |     fi | ||||||
|  |      | ||||||
|  |     cat >> "$diag_file" << EOF | ||||||
|  |  | ||||||
|  | === Firewall Status (UFW) === | ||||||
|  | $(ufw status 2>/dev/null || echo "UFW not available") | ||||||
|  |  | ||||||
|  | === Firewall Status (iptables) === | ||||||
|  | $(iptables -L -n | head -50 2>/dev/null || echo "iptables not available") | ||||||
|  |  | ||||||
|  | =================================================== | ||||||
|  | EOF | ||||||
|  |      | ||||||
|  |     # ======================================== | ||||||
|  |     # 10. Process Information | ||||||
|  |     # ======================================== | ||||||
|  |     print_info "Collecting process information..." | ||||||
|  |      | ||||||
|  |     cat >> "$diag_file" << EOF | ||||||
|  | =================================================== | ||||||
|  | Process Information | ||||||
|  | =================================================== | ||||||
|  |  | ||||||
|  | === PatchMon Node Processes === | ||||||
|  | $(ps aux | grep -E "node.*$instance_dir|PID" | grep -v grep || echo "No processes found") | ||||||
|  |  | ||||||
|  | === Top Processes (CPU) === | ||||||
|  | $(ps aux --sort=-%cpu | head -15) | ||||||
|  |  | ||||||
|  | === Top Processes (Memory) === | ||||||
|  | $(ps aux --sort=-%mem | head -15) | ||||||
|  |  | ||||||
|  | =================================================== | ||||||
|  | EOF | ||||||
|  |      | ||||||
|  |     # ======================================== | ||||||
|  |     # 11. SSL Certificate Information | ||||||
|  |     # ======================================== | ||||||
|  |     print_info "Collecting SSL certificate information..." | ||||||
|  |      | ||||||
|  |     cat >> "$diag_file" << EOF | ||||||
|  | =================================================== | ||||||
|  | SSL Certificate Information | ||||||
|  | =================================================== | ||||||
|  |  | ||||||
|  | === Certbot Certificates === | ||||||
|  | $(certbot certificates 2>/dev/null || echo "Certbot not available or no certificates") | ||||||
|  |  | ||||||
|  | === SSL Certificate Files === | ||||||
|  | $(ls -lh /etc/letsencrypt/live/$instance_name/ 2>/dev/null || echo "No SSL certificates found for $instance_name") | ||||||
|  |  | ||||||
|  | =================================================== | ||||||
|  | EOF | ||||||
|  |      | ||||||
|  |     # ======================================== | ||||||
|  |     # 12. Recent System Logs | ||||||
|  |     # ======================================== | ||||||
|  |     print_info "Collecting recent system logs..." | ||||||
|  |      | ||||||
|  |     journalctl -n 200 --no-pager >> "$diag_file" 2>&1 || \ | ||||||
|  |         echo "Could not retrieve system logs" >> "$diag_file" | ||||||
|  |      | ||||||
|  |     # ======================================== | ||||||
|  |     # 13. Installation Log (if exists) | ||||||
|  |     # ======================================== | ||||||
|  |     print_info "Collecting installation log..." | ||||||
|  |      | ||||||
|  |     echo "" >> "$diag_file" | ||||||
|  |     echo "=== Installation Log (last 200 lines) ===" >> "$diag_file" | ||||||
|  |     if [ -f "$instance_dir/patchmon-install.log" ]; then | ||||||
|  |         tail -200 "$instance_dir/patchmon-install.log" >> "$diag_file" 2>&1 | ||||||
|  |     else | ||||||
|  |         echo "No installation log found" >> "$diag_file" | ||||||
|  |     fi | ||||||
|  |     echo "" >> "$diag_file" | ||||||
|  |      | ||||||
|  |     # ======================================== | ||||||
|  |     # 14. Node.js and npm Information | ||||||
|  |     # ======================================== | ||||||
|  |     print_info "Collecting Node.js information..." | ||||||
|  |      | ||||||
|  |     cat >> "$diag_file" << EOF | ||||||
|  | =================================================== | ||||||
|  | Node.js and npm Information | ||||||
|  | =================================================== | ||||||
|  |  | ||||||
|  | === Node.js Version === | ||||||
|  | $(node --version 2>/dev/null || echo "Node.js not found") | ||||||
|  |  | ||||||
|  | === npm Version === | ||||||
|  | $(npm --version 2>/dev/null || echo "npm not found") | ||||||
|  |  | ||||||
|  | === Backend Dependencies === | ||||||
|  | $(cd "$instance_dir/backend" && npm list --depth=0 2>/dev/null || echo "Could not list backend dependencies") | ||||||
|  |  | ||||||
|  | =================================================== | ||||||
|  | EOF | ||||||
|  |      | ||||||
|  |     # ======================================== | ||||||
|  |     # Finalize diagnostics file | ||||||
|  |     # ======================================== | ||||||
|  |     print_info "Finalizing diagnostics file..." | ||||||
|  |      | ||||||
|  |     echo "" >> "$diag_file" | ||||||
|  |     echo "====================================================" >> "$diag_file" | ||||||
|  |     echo "END OF DIAGNOSTICS REPORT" >> "$diag_file" | ||||||
|  |     echo "====================================================" >> "$diag_file" | ||||||
|  |     echo "" >> "$diag_file" | ||||||
|  |     echo "IMPORTANT: Sensitive Information" >> "$diag_file" | ||||||
|  |     echo "Passwords, secrets, and tokens have been sanitized" >> "$diag_file" | ||||||
|  |     echo "and replaced with [REDACTED]. However, please review" >> "$diag_file" | ||||||
|  |     echo "before sharing to ensure no sensitive data is included." >> "$diag_file" | ||||||
|  |     echo "====================================================" >> "$diag_file" | ||||||
|  |      | ||||||
|  |     print_status "Diagnostics file created: $diag_file" | ||||||
|  |      | ||||||
|  |     # ======================================== | ||||||
|  |     # Display summary | ||||||
|  |     # ======================================== | ||||||
|  |     echo "" | ||||||
|  |     echo -e "${GREEN}====================================================${NC}" | ||||||
|  |     echo -e "${GREEN}     Diagnostics Collection Complete!${NC}" | ||||||
|  |     echo -e "${GREEN}====================================================${NC}" | ||||||
|  |     echo "" | ||||||
|  |      | ||||||
|  |     # Get service statuses and file size | ||||||
|  |     local service_status=$(systemctl is-active "$instance_name" 2>/dev/null || echo "unknown") | ||||||
|  |     local nginx_status=$(systemctl is-active nginx 2>/dev/null || echo "unknown") | ||||||
|  |     local postgres_status=$(systemctl is-active postgresql 2>/dev/null || echo "unknown") | ||||||
|  |     local redis_status=$(systemctl is-active redis-server 2>/dev/null || echo "unknown") | ||||||
|  |     local file_size=$(du -h "$diag_file" 2>/dev/null | cut -f1 || echo "unknown") | ||||||
|  |     local line_count=$(wc -l < "$diag_file" 2>/dev/null || echo "unknown") | ||||||
|  |      | ||||||
|  |     # Get connection counts for summary | ||||||
|  |     local backend_port=$(grep '^PORT=' "$instance_dir/backend/.env" 2>/dev/null | cut -d'=' -f2 | tr -d ' ' || echo "3000") | ||||||
|  |     local backend_conn_count=$(ss -tn 2>/dev/null | grep ":$backend_port" | wc -l || echo "0") | ||||||
|  |      | ||||||
|  |     local db_conn_count="N/A" | ||||||
|  |     if [ -n "$DB_PASS" ] && [ -n "$DB_USER" ] && [ -n "$DB_NAME" ]; then | ||||||
|  |         db_conn_count=$(PGPASSWORD="$DB_PASS" psql -h "${DB_HOST:-localhost}" -U "$DB_USER" -d "$DB_NAME" -t -A -c "SELECT count(*) FROM pg_stat_activity WHERE datname = '$DB_NAME';" 2>/dev/null || echo "N/A") | ||||||
|  |     fi | ||||||
|  |      | ||||||
|  |     local redis_conn_count="N/A" | ||||||
|  |     if [ -n "$REDIS_USER" ] && [ -n "$REDIS_PASSWORD" ]; then | ||||||
|  |         redis_conn_count=$(redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" --user "$REDIS_USER" --pass "$REDIS_PASSWORD" --no-auth-warning INFO clients 2>/dev/null | grep "connected_clients:" | cut -d':' -f2 | tr -d '\r' || echo "N/A") | ||||||
|  |     elif [ -n "$REDIS_PASSWORD" ]; then | ||||||
|  |         redis_conn_count=$(redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" -a "$REDIS_PASSWORD" --no-auth-warning INFO clients 2>/dev/null | grep "connected_clients:" | cut -d':' -f2 | tr -d '\r' || echo "N/A") | ||||||
|  |     fi | ||||||
|  |      | ||||||
|  |     # Compact, copyable summary | ||||||
|  |     echo -e "${BLUE}═══════════════════════════════════════════════════${NC}" | ||||||
|  |     echo -e "${BLUE}DIAGNOSTICS SUMMARY (copy-paste friendly)${NC}" | ||||||
|  |     echo -e "${BLUE}═══════════════════════════════════════════════════${NC}" | ||||||
|  |     echo "Instance: $instance_name" | ||||||
|  |     echo "File: $diag_file" | ||||||
|  |     echo "Size: $file_size ($line_count lines)" | ||||||
|  |     echo "Generated: $(date '+%Y-%m-%d %H:%M:%S')" | ||||||
|  |     echo "---" | ||||||
|  |     echo "Service Status: $service_status" | ||||||
|  |     echo "Nginx Status: $nginx_status" | ||||||
|  |     echo "PostgreSQL: $postgres_status" | ||||||
|  |     echo "Redis: $redis_status" | ||||||
|  |     echo "---" | ||||||
|  |     echo "Backend Port: $backend_port (Active Connections: $backend_conn_count)" | ||||||
|  |     echo "Database Connections: $db_conn_count" | ||||||
|  |     echo "Redis Connections: $redis_conn_count" | ||||||
|  |     echo "---" | ||||||
|  |     echo "View: cat $(basename "$diag_file")" | ||||||
|  |     echo "Or: less $(basename "$diag_file")" | ||||||
|  |     echo "Share: Send $(basename "$diag_file") to support" | ||||||
|  |     echo -e "${BLUE}═══════════════════════════════════════════════════${NC}" | ||||||
|  |     echo "" | ||||||
|  |     print_warning "Review file before sharing - sensitive data has been sanitized" | ||||||
|  |     echo "" | ||||||
|  |      | ||||||
|  |     print_success "Done!" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | # Run main function | ||||||
|  | main "$@" | ||||||
|  |  | ||||||
							
								
								
									
										286
									
								
								tools/fix-migrations.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										286
									
								
								tools/fix-migrations.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,286 @@ | |||||||
|  | #!/bin/bash | ||||||
|  | # PatchMon Migration Fixer | ||||||
|  | # Standalone script to detect and fix failed Prisma migrations | ||||||
|  | # Usage: sudo bash fix-migrations.sh [instance-name] | ||||||
|  |  | ||||||
|  | set -e | ||||||
|  |  | ||||||
|  | # Colors for output | ||||||
|  | RED='\033[0;31m' | ||||||
|  | GREEN='\033[0;32m' | ||||||
|  | YELLOW='\033[1;33m' | ||||||
|  | BLUE='\033[0;34m' | ||||||
|  | NC='\033[0m' # No Color | ||||||
|  |  | ||||||
|  | # Print functions | ||||||
|  | print_status() { | ||||||
|  |     echo -e "${GREEN}✅ $1${NC}" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | print_info() { | ||||||
|  |     echo -e "${BLUE}ℹ️  $1${NC}" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | print_error() { | ||||||
|  |     echo -e "${RED}❌ $1${NC}" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | print_warning() { | ||||||
|  |     echo -e "${YELLOW}⚠️  $1${NC}" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | # Check if running as root | ||||||
|  | if [[ $EUID -ne 0 ]]; then | ||||||
|  |     print_error "This script must be run as root" | ||||||
|  |     print_info "Please run: sudo bash $0" | ||||||
|  |     exit 1 | ||||||
|  | fi | ||||||
|  |  | ||||||
|  | # Function to detect PatchMon installations | ||||||
|  | detect_installations() { | ||||||
|  |     local installations=() | ||||||
|  |      | ||||||
|  |     if [ -d "/opt" ]; then | ||||||
|  |         for dir in /opt/*/; do | ||||||
|  |             local dirname=$(basename "$dir") | ||||||
|  |             # Skip backup directories | ||||||
|  |             if [[ "$dirname" =~ \.backup\. ]]; then | ||||||
|  |                 continue | ||||||
|  |             fi | ||||||
|  |             # Check if it's a PatchMon installation | ||||||
|  |             if [ -f "$dir/backend/package.json" ] && grep -q "patchmon" "$dir/backend/package.json" 2>/dev/null; then | ||||||
|  |                 installations+=("$dirname") | ||||||
|  |             fi | ||||||
|  |         done | ||||||
|  |     fi | ||||||
|  |      | ||||||
|  |     echo "${installations[@]}" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | # Function to select installation | ||||||
|  | select_installation() { | ||||||
|  |     local installations=($(detect_installations)) | ||||||
|  |      | ||||||
|  |     if [ ${#installations[@]} -eq 0 ]; then | ||||||
|  |         print_error "No PatchMon installations found in /opt" | ||||||
|  |         exit 1 | ||||||
|  |     fi | ||||||
|  |      | ||||||
|  |     if [ -n "$1" ]; then | ||||||
|  |         # Use provided instance name | ||||||
|  |         if [[ " ${installations[@]} " =~ " $1 " ]]; then | ||||||
|  |             echo "$1" | ||||||
|  |             return 0 | ||||||
|  |         else | ||||||
|  |             print_error "Instance '$1' not found" | ||||||
|  |             exit 1 | ||||||
|  |         fi | ||||||
|  |     fi | ||||||
|  |      | ||||||
|  |     print_info "Found ${#installations[@]} installation(s):" | ||||||
|  |     echo "" | ||||||
|  |      | ||||||
|  |     local i=1 | ||||||
|  |     declare -A install_map | ||||||
|  |     for install in "${installations[@]}"; do | ||||||
|  |         printf "%2d. %s\n" "$i" "$install" | ||||||
|  |         install_map[$i]="$install" | ||||||
|  |         i=$((i + 1)) | ||||||
|  |     done | ||||||
|  |      | ||||||
|  |     echo "" | ||||||
|  |     echo -n -e "${BLUE}Select installation number [1]: ${NC}" | ||||||
|  |     read -r selection | ||||||
|  |      | ||||||
|  |     selection=${selection:-1} | ||||||
|  |      | ||||||
|  |     if [[ "$selection" =~ ^[0-9]+$ ]] && [ -n "${install_map[$selection]}" ]; then | ||||||
|  |         echo "${install_map[$selection]}" | ||||||
|  |         return 0 | ||||||
|  |     else | ||||||
|  |         print_error "Invalid selection" | ||||||
|  |         exit 1 | ||||||
|  |     fi | ||||||
|  | } | ||||||
|  |  | ||||||
|  | # Function to check and fix failed migrations | ||||||
|  | fix_failed_migrations() { | ||||||
|  |     local db_name="$1" | ||||||
|  |     local db_user="$2" | ||||||
|  |     local db_pass="$3" | ||||||
|  |     local db_host="${4:-localhost}" | ||||||
|  |      | ||||||
|  |     print_info "Checking for failed migrations in database..." | ||||||
|  |      | ||||||
|  |     # Query for failed migrations | ||||||
|  |     local failed_migrations | ||||||
|  |     failed_migrations=$(PGPASSWORD="$db_pass" psql -h "$db_host" -U "$db_user" -d "$db_name" -t -A -c \ | ||||||
|  |         "SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NULL AND started_at IS NOT NULL;" 2>/dev/null || echo "") | ||||||
|  |      | ||||||
|  |     if [ -z "$failed_migrations" ]; then | ||||||
|  |         print_status "No failed migrations found" | ||||||
|  |         return 0 | ||||||
|  |     fi | ||||||
|  |      | ||||||
|  |     print_warning "Found failed migration(s):" | ||||||
|  |     echo "$failed_migrations" | while read -r migration; do | ||||||
|  |         [ -n "$migration" ] && print_warning "  - $migration" | ||||||
|  |     done | ||||||
|  |     echo "" | ||||||
|  |      | ||||||
|  |     print_info "What would you like to do?" | ||||||
|  |     echo "  1. Clean and retry (delete failed records and re-run migration)" | ||||||
|  |     echo "  2. Mark as completed (if schema changes are already applied)" | ||||||
|  |     echo "  3. Show migration details only" | ||||||
|  |     echo "  4. Cancel" | ||||||
|  |     echo "" | ||||||
|  |     echo -n -e "${BLUE}Select option [1]: ${NC}" | ||||||
|  |     read -r option | ||||||
|  |      | ||||||
|  |     option=${option:-1} | ||||||
|  |      | ||||||
|  |     case $option in | ||||||
|  |         1) | ||||||
|  |             print_info "Cleaning failed migrations and preparing for retry..." | ||||||
|  |             echo "$failed_migrations" | while read -r migration; do | ||||||
|  |                 if [ -n "$migration" ]; then | ||||||
|  |                     print_info "Processing: $migration" | ||||||
|  |                      | ||||||
|  |                     # Mark as rolled back | ||||||
|  |                     PGPASSWORD="$db_pass" psql -h "$db_host" -U "$db_user" -d "$db_name" -c \ | ||||||
|  |                         "UPDATE _prisma_migrations SET rolled_back_at = NOW() WHERE migration_name = '$migration' AND finished_at IS NULL;" >/dev/null 2>&1 | ||||||
|  |                      | ||||||
|  |                     # Delete the failed record | ||||||
|  |                     PGPASSWORD="$db_pass" psql -h "$db_host" -U "$db_user" -d "$db_name" -c \ | ||||||
|  |                         "DELETE FROM _prisma_migrations WHERE migration_name = '$migration' AND finished_at IS NULL;" >/dev/null 2>&1 | ||||||
|  |                      | ||||||
|  |                     print_status "Cleared: $migration" | ||||||
|  |                 fi | ||||||
|  |             done | ||||||
|  |             print_status "Failed migrations cleared - ready to retry" | ||||||
|  |             return 0 | ||||||
|  |             ;; | ||||||
|  |         2) | ||||||
|  |             print_info "Marking migrations as completed..." | ||||||
|  |             echo "$failed_migrations" | while read -r migration; do | ||||||
|  |                 if [ -n "$migration" ]; then | ||||||
|  |                     print_info "Marking as complete: $migration" | ||||||
|  |                      | ||||||
|  |                     PGPASSWORD="$db_pass" psql -h "$db_host" -U "$db_user" -d "$db_name" -c \ | ||||||
|  |                         "UPDATE _prisma_migrations SET finished_at = NOW(), logs = 'Manually resolved by fix-migrations.sh' WHERE migration_name = '$migration' AND finished_at IS NULL;" >/dev/null 2>&1 | ||||||
|  |                      | ||||||
|  |                     print_status "Marked complete: $migration" | ||||||
|  |                 fi | ||||||
|  |             done | ||||||
|  |             print_status "All migrations marked as completed" | ||||||
|  |             return 0 | ||||||
|  |             ;; | ||||||
|  |         3) | ||||||
|  |             print_info "Migration details:" | ||||||
|  |             PGPASSWORD="$db_pass" psql -h "$db_host" -U "$db_user" -d "$db_name" -c \ | ||||||
|  |                 "SELECT migration_name, started_at, finished_at, rolled_back_at, logs FROM _prisma_migrations WHERE finished_at IS NULL AND started_at IS NOT NULL;" | ||||||
|  |             return 0 | ||||||
|  |             ;; | ||||||
|  |         4) | ||||||
|  |             print_info "Cancelled" | ||||||
|  |             return 1 | ||||||
|  |             ;; | ||||||
|  |         *) | ||||||
|  |             print_error "Invalid option" | ||||||
|  |             return 1 | ||||||
|  |             ;; | ||||||
|  |     esac | ||||||
|  | } | ||||||
|  |  | ||||||
|  | # Main script | ||||||
|  | main() { | ||||||
|  |     echo -e "${BLUE}====================================================${NC}" | ||||||
|  |     echo -e "${BLUE}        PatchMon Migration Fixer${NC}" | ||||||
|  |     echo -e "${BLUE}====================================================${NC}" | ||||||
|  |     echo "" | ||||||
|  |      | ||||||
|  |     # Select instance | ||||||
|  |     instance_name=$(select_installation "$1") | ||||||
|  |     instance_dir="/opt/$instance_name" | ||||||
|  |      | ||||||
|  |     print_info "Selected instance: $instance_name" | ||||||
|  |     print_info "Directory: $instance_dir" | ||||||
|  |     echo "" | ||||||
|  |      | ||||||
|  |     # Load .env to get database credentials | ||||||
|  |     if [ ! -f "$instance_dir/backend/.env" ]; then | ||||||
|  |         print_error "Cannot find .env file at $instance_dir/backend/.env" | ||||||
|  |         exit 1 | ||||||
|  |     fi | ||||||
|  |      | ||||||
|  |     # Source .env | ||||||
|  |     set -a | ||||||
|  |     source "$instance_dir/backend/.env" | ||||||
|  |     set +a | ||||||
|  |      | ||||||
|  |     # Parse DATABASE_URL | ||||||
|  |     if [ -z "$DATABASE_URL" ]; then | ||||||
|  |         print_error "DATABASE_URL not found in .env file" | ||||||
|  |         exit 1 | ||||||
|  |     fi | ||||||
|  |      | ||||||
|  |     DB_USER=$(echo "$DATABASE_URL" | sed -n 's|postgresql://\([^:]*\):.*|\1|p') | ||||||
|  |     DB_PASS=$(echo "$DATABASE_URL" | sed -n 's|postgresql://[^:]*:\([^@]*\)@.*|\1|p') | ||||||
|  |     DB_HOST=$(echo "$DATABASE_URL" | sed -n 's|.*@\([^:]*\):.*|\1|p') | ||||||
|  |     DB_NAME=$(echo "$DATABASE_URL" | sed -n 's|.*/\([^?]*\).*|\1|p') | ||||||
|  |      | ||||||
|  |     print_info "Database: $DB_NAME" | ||||||
|  |     print_info "User: $DB_USER" | ||||||
|  |     print_info "Host: $DB_HOST" | ||||||
|  |     echo "" | ||||||
|  |      | ||||||
|  |     # Test database connection | ||||||
|  |     print_info "Testing database connection..." | ||||||
|  |     if ! PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c "SELECT 1;" >/dev/null 2>&1; then | ||||||
|  |         print_error "Cannot connect to database" | ||||||
|  |         exit 1 | ||||||
|  |     fi | ||||||
|  |     print_status "Database connection successful" | ||||||
|  |     echo "" | ||||||
|  |      | ||||||
|  |     # Check Prisma migration status | ||||||
|  |     print_info "Checking Prisma migration status..." | ||||||
|  |     cd "$instance_dir/backend" | ||||||
|  |      | ||||||
|  |     echo "" | ||||||
|  |     echo -e "${YELLOW}=== Prisma Migration Status ===${NC}" | ||||||
|  |     npx prisma migrate status 2>&1 || true | ||||||
|  |     echo -e "${YELLOW}==============================${NC}" | ||||||
|  |     echo "" | ||||||
|  |      | ||||||
|  |     # Check for failed migrations | ||||||
|  |     fix_failed_migrations "$DB_NAME" "$DB_USER" "$DB_PASS" "$DB_HOST" | ||||||
|  |      | ||||||
|  |     # Ask if user wants to run migrations now | ||||||
|  |     echo "" | ||||||
|  |     echo -n -e "${BLUE}Do you want to run 'npx prisma migrate deploy' now? [y/N]: ${NC}" | ||||||
|  |     read -r run_migrate | ||||||
|  |      | ||||||
|  |     if [[ "$run_migrate" =~ ^[Yy] ]]; then | ||||||
|  |         print_info "Running migrations..." | ||||||
|  |         cd "$instance_dir/backend" | ||||||
|  |          | ||||||
|  |         if npx prisma migrate deploy; then | ||||||
|  |             print_status "Migrations completed successfully!" | ||||||
|  |         else | ||||||
|  |             print_error "Migration failed" | ||||||
|  |             print_info "You may need to run this script again or investigate further" | ||||||
|  |             exit 1 | ||||||
|  |         fi | ||||||
|  |     else | ||||||
|  |         print_info "Skipped migration deployment" | ||||||
|  |         print_info "Run manually: cd $instance_dir/backend && npx prisma migrate deploy" | ||||||
|  |     fi | ||||||
|  |      | ||||||
|  |     echo "" | ||||||
|  |     print_status "Done!" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | # Run main function | ||||||
|  | main "$@" | ||||||
|  |  | ||||||
							
								
								
									
										41
									
								
								tools/fixconnlimit.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								tools/fixconnlimit.sh
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | |||||||
|  | #!/bin/bash | ||||||
|  |  | ||||||
|  | # Script to update hardcoded connection pool values in prisma.js | ||||||
|  | # Usage: ./update_pool_values.sh [connection_limit] [pool_timeout] [connect_timeout] [idle_timeout] [max_lifetime] | ||||||
|  |  | ||||||
|  | set -e | ||||||
|  |  | ||||||
|  | FILE="${1:-backend/src/config/prisma.js}" | ||||||
|  |  | ||||||
|  | # Get values from arguments or use defaults | ||||||
|  | NEW_CONN_LIMIT="${2:-30}" | ||||||
|  | NEW_POOL_TIMEOUT="${3:-20}" | ||||||
|  | NEW_CONNECT_TIMEOUT="${4:-10}" | ||||||
|  | NEW_IDLE_TIMEOUT="${5:-300}" | ||||||
|  | NEW_MAX_LIFETIME="${6:-1800}" | ||||||
|  |  | ||||||
|  | if [ ! -f "$FILE" ]; then | ||||||
|  |     echo "Error: File not found: $FILE" | ||||||
|  |     exit 1 | ||||||
|  | fi | ||||||
|  |  | ||||||
|  | # Create backup | ||||||
|  | BACKUP_FILE="${FILE}.backup.$(date +%Y%m%d_%H%M%S)" | ||||||
|  | cp "$FILE" "$BACKUP_FILE" | ||||||
|  | echo "Backup created: $BACKUP_FILE" | ||||||
|  |  | ||||||
|  | # Replace the hardcoded values | ||||||
|  | sed -i "s|url\.searchParams\.set(\"connection_limit\", \".*\");|url.searchParams.set(\"connection_limit\", \"$NEW_CONN_LIMIT\");|g" "$FILE" | ||||||
|  | sed -i "s|url\.searchParams\.set(\"pool_timeout\", \".*\");|url.searchParams.set(\"pool_timeout\", \"$NEW_POOL_TIMEOUT\");|g" "$FILE" | ||||||
|  | sed -i "s|url\.searchParams\.set(\"connect_timeout\", \".*\");|url.searchParams.set(\"connect_timeout\", \"$NEW_CONNECT_TIMEOUT\");|g" "$FILE" | ||||||
|  | sed -i "s|url\.searchParams\.set(\"idle_timeout\", \".*\");|url.searchParams.set(\"idle_timeout\", \"$NEW_IDLE_TIMEOUT\");|g" "$FILE" | ||||||
|  | sed -i "s|url\.searchParams\.set(\"max_lifetime\", \".*\");|url.searchParams.set(\"max_lifetime\", \"$NEW_MAX_LIFETIME\");|g" "$FILE" | ||||||
|  |  | ||||||
|  | echo "Updated values:" | ||||||
|  | echo "  connection_limit: $NEW_CONN_LIMIT" | ||||||
|  | echo "  pool_timeout: $NEW_POOL_TIMEOUT" | ||||||
|  | echo "  connect_timeout: $NEW_CONNECT_TIMEOUT" | ||||||
|  | echo "  idle_timeout: $NEW_IDLE_TIMEOUT" | ||||||
|  | echo "  max_lifetime: $NEW_MAX_LIFETIME" | ||||||
|  | echo "" | ||||||
|  | echo "Changes applied to $FILE" | ||||||
							
								
								
									
										128
									
								
								tools/fixconnstrings.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										128
									
								
								tools/fixconnstrings.sh
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,128 @@ | |||||||
|  | #!/bin/bash | ||||||
|  |  | ||||||
|  | # Script to fix HTTP connection limit issue for hosts page | ||||||
|  | # This adds a bulk status endpoint and updates the frontend to use it | ||||||
|  |  | ||||||
|  | set -e | ||||||
|  |  | ||||||
|  | echo "🔧 Fixing HTTP connection limit issue..." | ||||||
|  |  | ||||||
|  | # Backup files first | ||||||
|  | echo "📦 Creating backups..." | ||||||
|  | cp backend/src/routes/wsRoutes.js backend/src/routes/wsRoutes.js.bak | ||||||
|  | cp frontend/src/pages/Hosts.jsx frontend/src/pages/Hosts.jsx.bak | ||||||
|  |  | ||||||
|  | # Add bulk status endpoint to wsRoutes.js | ||||||
|  | echo "➕ Adding bulk status endpoint to backend..." | ||||||
|  |  | ||||||
|  | cat > /tmp/ws_routes_addition.txt << 'EOF' | ||||||
|  | // Get WebSocket connection status for multiple hosts at once | ||||||
|  | router.get("/status", authenticateToken, async (req, res) => { | ||||||
|  | 	try { | ||||||
|  | 		const { apiIds } = req.query; // Comma-separated list of api_ids | ||||||
|  | 		const idArray = apiIds ? apiIds.split(',').filter(id => id.trim()) : []; | ||||||
|  | 		 | ||||||
|  | 		const statusMap = {}; | ||||||
|  | 		idArray.forEach(apiId => { | ||||||
|  | 			statusMap[apiId] = getConnectionInfo(apiId); | ||||||
|  | 		}); | ||||||
|  | 		 | ||||||
|  | 		res.json({ | ||||||
|  | 			success: true, | ||||||
|  | 			data: statusMap, | ||||||
|  | 		}); | ||||||
|  | 	} catch (error) { | ||||||
|  | 		console.error("Error fetching bulk WebSocket status:", error); | ||||||
|  | 		res.status(500).json({ | ||||||
|  | 			success: false, | ||||||
|  | 			error: "Failed to fetch WebSocket status", | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  | }); | ||||||
|  | EOF | ||||||
|  |  | ||||||
|  | # Find the line number of the first router.get and insert after it | ||||||
|  | LINENUM=$(grep -n "router.get.*status.*apiId" backend/src/routes/wsRoutes.js | head -1 | cut -d: -f1) | ||||||
|  | sed -i "${LINENUM}r /tmp/ws_routes_addition.txt" backend/src/routes/wsRoutes.js | ||||||
|  |  | ||||||
|  | # Now update the frontend to use the bulk endpoint | ||||||
|  | echo "🔄 Updating frontend to use bulk endpoint..." | ||||||
|  |  | ||||||
|  | # Create a sed script to replace the fetchInitialStatus function | ||||||
|  | cat > /tmp/hosts_fix.sed << 'EOF' | ||||||
|  | /const fetchInitialStatus = async/,\}/c\ | ||||||
|  | 	const fetchInitialStatus = async () => {\ | ||||||
|  | 		const apiIds = hosts\ | ||||||
|  | 			.filter((host) => host.api_id)\ | ||||||
|  | 			.map(host => host.api_id);\ | ||||||
|  | 		\ | ||||||
|  | 		if (apiIds.length === 0) return;\ | ||||||
|  | 		\ | ||||||
|  | 		try {\ | ||||||
|  | 			const response = await fetch(`/api/v1/ws/status?apiIds=${apiIds.join(',')}`, {\ | ||||||
|  | 				headers: {\ | ||||||
|  | 					Authorization: `Bearer ${token}`,\ | ||||||
|  | 				},\ | ||||||
|  | 			});\ | ||||||
|  | 			if (response.ok) {\ | ||||||
|  | 				const result = await response.json();\ | ||||||
|  | 				setWsStatusMap(result.data);\ | ||||||
|  | 			}\ | ||||||
|  | 		} catch (_error) {\ | ||||||
|  | 			// Silently handle errors\ | ||||||
|  | 		}\ | ||||||
|  | 	}; | ||||||
|  | EOF | ||||||
|  |  | ||||||
|  | # Apply the sed script (multiline replacement is tricky with sed, so we'll use a different approach) | ||||||
|  | echo "✨ Using awk for multi-line replacement..." | ||||||
|  |  | ||||||
|  | # Create a temporary awk script | ||||||
|  | cat > /tmp/update_hosts.awk << 'AWK_EOF' | ||||||
|  | BEGIN { in_function=0; brace_count=0 } | ||||||
|  | /store.fetchInitialStatus/ { printing=1 } | ||||||
|  | /const fetchInitialStatus = async/ {  | ||||||
|  |     print "			// Fetch initial WebSocket status for all hosts";  | ||||||
|  |     print "			const fetchInitialStatus = async () => {";  | ||||||
|  |     print "				const apiIds = hosts";  | ||||||
|  |     print "					.filter((host) => host.api_id)";  | ||||||
|  |     print "					.map(host => host.api_id);";  | ||||||
|  |     print "";  | ||||||
|  |     print "				if (apiIds.length === 0) return;";  | ||||||
|  |     print "";  | ||||||
|  |     print "				try {";  | ||||||
|  |     print "					const response = await fetch(`/api/v1/ws/status?apiIds=${apiIds.join(',')}`, {";  | ||||||
|  |     print "						headers: {";  | ||||||
|  |     print "							Authorization: `Bearer ${token}`,";  | ||||||
|  |     print "						},";  | ||||||
|  |     print "					});";  | ||||||
|  |     print "					if (response.ok) {";  | ||||||
|  |     print "						const result = await response.json();";  | ||||||
|  |     print "						setWsStatusMap(result.data);";  | ||||||
|  |     print "					}";  | ||||||
|  |     print "				} catch (_error) {";  | ||||||
|  |     print "					// Silently handle errors";  | ||||||
|  |     print "				}";  | ||||||
|  |     print "			};";  | ||||||
|  |     skipping=1;  | ||||||
|  |     next  | ||||||
|  | } | ||||||
|  | skipping && /^\t\t\}/ { skipping=0; next } | ||||||
|  | skipping { next } | ||||||
|  | { print } | ||||||
|  | AWK_EOF | ||||||
|  |  | ||||||
|  | awk -f /tmp/update_hosts.awk frontend/src/pages/Hosts.jsx.bak > frontend/src/pages/Hosts.jsx | ||||||
|  |  | ||||||
|  | # Clean up temp files | ||||||
|  | rm /tmp/ws_routes_addition.txt /tmp/hosts_fix.sed /tmp/update_hosts.awk | ||||||
|  |  | ||||||
|  | echo "✅ Done! Files have been modified." | ||||||
|  | echo "" | ||||||
|  | echo "📝 Changes made:" | ||||||
|  | echo "   - backend/src/routes/wsRoutes.js: Added bulk status endpoint" | ||||||
|  | echo "   - frontend/src/pages/Hosts.jsx: Updated to use bulk endpoint" | ||||||
|  | echo "" | ||||||
|  | echo "💾 Backups saved as:" | ||||||
|  | echo "   - backend/src/routes/wsRoutes.js.bak" | ||||||
|  | echo "   - frontend/src/pages/Hosts.jsx.bak" | ||||||
		Reference in New Issue
	
	Block a user