mirror of
				https://github.com/9technologygroup/patchmon.net.git
				synced 2025-10-31 20:13:50 +00:00 
			
		
		
		
	Compare commits
	
		
			74 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 39fbafe01f | ||
|  | f296cf2003 | ||
|  | 052a77dce8 | ||
|  | 94bfffd882 | ||
|  | 37462f4831 | ||
|  | 5457a1e9bc | ||
|  | f3bca4a6d5 | ||
|  | ca4d34c230 | ||
|  | 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 | ||
|  | cd03f0e66a | ||
|  | deb6bed1a6 | ||
|  | 0189a307ef | ||
|  | 00abbc8c62 | ||
|  | c9aef78912 | ||
|  | fd2df0729e | ||
|  | d7f7b24f8f | ||
|  | 1ef2308d56 | ||
|  | fcd1b52e0e | ||
|  | 5be8e01aa3 | ||
|  | 293733dc0b | ||
|  | c7ab40e4a2 | 
										
											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 | ||||||
|   | |||||||
| @@ -3,6 +3,13 @@ DATABASE_URL="postgresql://patchmon_user:your-password-here@localhost:5432/patch | |||||||
| PM_DB_CONN_MAX_ATTEMPTS=30 | PM_DB_CONN_MAX_ATTEMPTS=30 | ||||||
| PM_DB_CONN_WAIT_INTERVAL=2 | PM_DB_CONN_WAIT_INTERVAL=2 | ||||||
|  |  | ||||||
|  | # Database Connection Pool Configuration (Prisma) | ||||||
|  | DB_CONNECTION_LIMIT=30       # Maximum connections per instance (default: 30) | ||||||
|  | DB_POOL_TIMEOUT=20          # Seconds to wait for available connection (default: 20) | ||||||
|  | DB_CONNECT_TIMEOUT=10       # Seconds to wait for initial connection (default: 10) | ||||||
|  | DB_IDLE_TIMEOUT=300         # Seconds before closing idle connections (default: 300) | ||||||
|  | DB_MAX_LIFETIME=1800        # Maximum lifetime of a connection in seconds (default: 1800) | ||||||
|  |  | ||||||
| # JWT Configuration | # JWT Configuration | ||||||
| JWT_SECRET=your-secure-random-secret-key-change-this-in-production | JWT_SECRET=your-secure-random-secret-key-change-this-in-production | ||||||
| JWT_EXPIRES_IN=1h | JWT_EXPIRES_IN=1h | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| { | { | ||||||
| 	"name": "patchmon-backend", | 	"name": "patchmon-backend", | ||||||
| 	"version": "1.3.0", | 	"version": "1.3.2", | ||||||
| 	"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", | ||||||
|   | |||||||
| @@ -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; | ||||||
|  |  | ||||||
| @@ -0,0 +1,74 @@ | |||||||
|  | -- CreateTable | ||||||
|  | CREATE TABLE "docker_volumes" ( | ||||||
|  |     "id" TEXT NOT NULL, | ||||||
|  |     "host_id" TEXT NOT NULL, | ||||||
|  |     "volume_id" TEXT NOT NULL, | ||||||
|  |     "name" TEXT NOT NULL, | ||||||
|  |     "driver" TEXT NOT NULL, | ||||||
|  |     "mountpoint" TEXT, | ||||||
|  |     "renderer" TEXT, | ||||||
|  |     "scope" TEXT NOT NULL DEFAULT 'local', | ||||||
|  |     "labels" JSONB, | ||||||
|  |     "options" JSONB, | ||||||
|  |     "size_bytes" BIGINT, | ||||||
|  |     "ref_count" INTEGER NOT NULL DEFAULT 0, | ||||||
|  |     "created_at" TIMESTAMP(3) NOT NULL, | ||||||
|  |     "updated_at" TIMESTAMP(3) NOT NULL, | ||||||
|  |     "last_checked" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||||||
|  |  | ||||||
|  |     CONSTRAINT "docker_volumes_pkey" PRIMARY KEY ("id") | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | -- CreateTable | ||||||
|  | CREATE TABLE "docker_networks" ( | ||||||
|  |     "id" TEXT NOT NULL, | ||||||
|  |     "host_id" TEXT NOT NULL, | ||||||
|  |     "network_id" TEXT NOT NULL, | ||||||
|  |     "name" TEXT NOT NULL, | ||||||
|  |     "driver" TEXT NOT NULL, | ||||||
|  |     "scope" TEXT NOT NULL DEFAULT 'local', | ||||||
|  |     "ipv6_enabled" BOOLEAN NOT NULL DEFAULT false, | ||||||
|  |     "internal" BOOLEAN NOT NULL DEFAULT false, | ||||||
|  |     "attachable" BOOLEAN NOT NULL DEFAULT true, | ||||||
|  |     "ingress" BOOLEAN NOT NULL DEFAULT false, | ||||||
|  |     "config_only" BOOLEAN NOT NULL DEFAULT false, | ||||||
|  |     "labels" JSONB, | ||||||
|  |     "ipam" JSONB, | ||||||
|  |     "container_count" INTEGER NOT NULL DEFAULT 0, | ||||||
|  |     "created_at" TIMESTAMP(3), | ||||||
|  |     "updated_at" TIMESTAMP(3) NOT NULL, | ||||||
|  |     "last_checked" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||||||
|  |  | ||||||
|  |     CONSTRAINT "docker_networks_pkey" PRIMARY KEY ("id") | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | -- CreateIndex | ||||||
|  | CREATE INDEX "docker_volumes_host_id_idx" ON "docker_volumes"("host_id"); | ||||||
|  |  | ||||||
|  | -- CreateIndex | ||||||
|  | CREATE INDEX "docker_volumes_name_idx" ON "docker_volumes"("name"); | ||||||
|  |  | ||||||
|  | -- CreateIndex | ||||||
|  | CREATE INDEX "docker_volumes_driver_idx" ON "docker_volumes"("driver"); | ||||||
|  |  | ||||||
|  | -- CreateIndex | ||||||
|  | CREATE UNIQUE INDEX "docker_volumes_host_id_volume_id_key" ON "docker_volumes"("host_id", "volume_id"); | ||||||
|  |  | ||||||
|  | -- CreateIndex | ||||||
|  | CREATE INDEX "docker_networks_host_id_idx" ON "docker_networks"("host_id"); | ||||||
|  |  | ||||||
|  | -- CreateIndex | ||||||
|  | CREATE INDEX "docker_networks_name_idx" ON "docker_networks"("name"); | ||||||
|  |  | ||||||
|  | -- CreateIndex | ||||||
|  | CREATE INDEX "docker_networks_driver_idx" ON "docker_networks"("driver"); | ||||||
|  |  | ||||||
|  | -- CreateIndex | ||||||
|  | CREATE UNIQUE INDEX "docker_networks_host_id_network_id_key" ON "docker_networks"("host_id", "network_id"); | ||||||
|  |  | ||||||
|  | -- AddForeignKey | ||||||
|  | ALTER TABLE "docker_volumes" ADD CONSTRAINT "docker_volumes_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE CASCADE ON UPDATE CASCADE; | ||||||
|  |  | ||||||
|  | -- AddForeignKey | ||||||
|  | ALTER TABLE "docker_networks" ADD CONSTRAINT "docker_networks_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE CASCADE ON UPDATE CASCADE; | ||||||
|  |  | ||||||
| @@ -0,0 +1,6 @@ | |||||||
|  | -- AlterTable | ||||||
|  | ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "theme_preference" VARCHAR(10) DEFAULT 'dark'; | ||||||
|  |  | ||||||
|  | -- AlterTable | ||||||
|  | ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "color_theme" VARCHAR(50) DEFAULT 'cyber_blue'; | ||||||
|  |  | ||||||
| @@ -114,6 +114,8 @@ model hosts { | |||||||
|   host_group_memberships  host_group_memberships[] |   host_group_memberships  host_group_memberships[] | ||||||
|   update_history          update_history[] |   update_history          update_history[] | ||||||
|   job_history             job_history[] |   job_history             job_history[] | ||||||
|  |   docker_volumes          docker_volumes[] | ||||||
|  |   docker_networks         docker_networks[] | ||||||
|  |  | ||||||
|   @@index([machine_id]) |   @@index([machine_id]) | ||||||
|   @@index([friendly_name]) |   @@index([friendly_name]) | ||||||
| @@ -170,27 +172,30 @@ model role_permissions { | |||||||
| } | } | ||||||
|  |  | ||||||
| model settings { | model settings { | ||||||
|   id                String    @id |   id                     String    @id | ||||||
|   server_url        String    @default("http://localhost:3001") |   server_url             String    @default("http://localhost:3001") | ||||||
|   server_protocol   String    @default("http") |   server_protocol        String    @default("http") | ||||||
|   server_host       String    @default("localhost") |   server_host            String    @default("localhost") | ||||||
|   server_port       Int       @default(3001) |   server_port            Int       @default(3001) | ||||||
|   created_at        DateTime  @default(now()) |   created_at             DateTime  @default(now()) | ||||||
|   updated_at        DateTime |   updated_at             DateTime | ||||||
|   update_interval   Int       @default(60) |   update_interval        Int       @default(60) | ||||||
|   auto_update       Boolean   @default(false) |   auto_update            Boolean   @default(false) | ||||||
|   github_repo_url   String    @default("https://github.com/PatchMon/PatchMon.git") |   github_repo_url        String    @default("https://github.com/PatchMon/PatchMon.git") | ||||||
|   ssh_key_path      String? |   ssh_key_path           String? | ||||||
|   repository_type   String    @default("public") |   repository_type        String    @default("public") | ||||||
|   last_update_check DateTime? |   last_update_check      DateTime? | ||||||
|   latest_version    String? |   latest_version         String? | ||||||
|   update_available  Boolean   @default(false) |   update_available       Boolean   @default(false) | ||||||
|   signup_enabled    Boolean   @default(false) |   signup_enabled         Boolean   @default(false) | ||||||
|   default_user_role String    @default("user") |   default_user_role      String    @default("user") | ||||||
|   ignore_ssl_self_signed Boolean @default(false) |   ignore_ssl_self_signed Boolean   @default(false) | ||||||
|   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? | ||||||
| } | } | ||||||
|  |  | ||||||
| model update_history { | model update_history { | ||||||
| @@ -222,6 +227,8 @@ model users { | |||||||
|   tfa_secret             String? |   tfa_secret             String? | ||||||
|   first_name             String? |   first_name             String? | ||||||
|   last_name              String? |   last_name              String? | ||||||
|  |   theme_preference       String?                  @default("dark") | ||||||
|  |   color_theme            String?                  @default("cyber_blue") | ||||||
|   dashboard_preferences  dashboard_preferences[] |   dashboard_preferences  dashboard_preferences[] | ||||||
|   user_sessions          user_sessions[] |   user_sessions          user_sessions[] | ||||||
|   auto_enrollment_tokens auto_enrollment_tokens[] |   auto_enrollment_tokens auto_enrollment_tokens[] | ||||||
| @@ -338,6 +345,56 @@ model docker_image_updates { | |||||||
|   @@index([is_security_update]) |   @@index([is_security_update]) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | model docker_volumes { | ||||||
|  |   id          String   @id | ||||||
|  |   host_id     String | ||||||
|  |   volume_id   String | ||||||
|  |   name        String | ||||||
|  |   driver      String | ||||||
|  |   mountpoint  String? | ||||||
|  |   renderer    String? | ||||||
|  |   scope       String   @default("local") | ||||||
|  |   labels      Json? | ||||||
|  |   options     Json? | ||||||
|  |   size_bytes  BigInt? | ||||||
|  |   ref_count   Int      @default(0) | ||||||
|  |   created_at  DateTime | ||||||
|  |   updated_at  DateTime | ||||||
|  |   last_checked DateTime @default(now()) | ||||||
|  |   hosts       hosts    @relation(fields: [host_id], references: [id], onDelete: Cascade) | ||||||
|  |  | ||||||
|  |   @@unique([host_id, volume_id]) | ||||||
|  |   @@index([host_id]) | ||||||
|  |   @@index([name]) | ||||||
|  |   @@index([driver]) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | model docker_networks { | ||||||
|  |   id             String   @id | ||||||
|  |   host_id        String | ||||||
|  |   network_id     String | ||||||
|  |   name           String | ||||||
|  |   driver         String | ||||||
|  |   scope          String   @default("local") | ||||||
|  |   ipv6_enabled   Boolean  @default(false) | ||||||
|  |   internal       Boolean  @default(false) | ||||||
|  |   attachable     Boolean  @default(true) | ||||||
|  |   ingress        Boolean  @default(false) | ||||||
|  |   config_only    Boolean  @default(false) | ||||||
|  |   labels         Json? | ||||||
|  |   ipam           Json?    // IPAM configuration (driver, config, options) | ||||||
|  |   container_count Int     @default(0) | ||||||
|  |   created_at     DateTime? | ||||||
|  |   updated_at     DateTime | ||||||
|  |   last_checked   DateTime @default(now()) | ||||||
|  |   hosts          hosts    @relation(fields: [host_id], references: [id], onDelete: Cascade) | ||||||
|  |  | ||||||
|  |   @@unique([host_id, network_id]) | ||||||
|  |   @@index([host_id]) | ||||||
|  |   @@index([name]) | ||||||
|  |   @@index([driver]) | ||||||
|  | } | ||||||
|  |  | ||||||
| model job_history { | model job_history { | ||||||
|   id            String   @id |   id            String   @id | ||||||
|   job_id        String |   job_id        String | ||||||
|   | |||||||
| @@ -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(); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -17,6 +17,7 @@ const { | |||||||
| 	refresh_access_token, | 	refresh_access_token, | ||||||
| 	revoke_session, | 	revoke_session, | ||||||
| 	revoke_all_user_sessions, | 	revoke_all_user_sessions, | ||||||
|  | 	generate_device_fingerprint, | ||||||
| } = require("../utils/session_manager"); | } = require("../utils/session_manager"); | ||||||
|  |  | ||||||
| const router = express.Router(); | const router = express.Router(); | ||||||
| @@ -788,11 +789,39 @@ router.post( | |||||||
|  |  | ||||||
| 			// Check if TFA is enabled | 			// Check if TFA is enabled | ||||||
| 			if (user.tfa_enabled) { | 			if (user.tfa_enabled) { | ||||||
| 				return res.status(200).json({ | 				// Get device fingerprint from X-Device-ID header | ||||||
| 					message: "TFA verification required", | 				const device_fingerprint = generate_device_fingerprint(req); | ||||||
| 					requiresTfa: true, |  | ||||||
| 					username: user.username, | 				// Check if this device has a valid TFA bypass | ||||||
| 				}); | 				if (device_fingerprint) { | ||||||
|  | 					const remembered_session = await prisma.user_sessions.findFirst({ | ||||||
|  | 						where: { | ||||||
|  | 							user_id: user.id, | ||||||
|  | 							device_fingerprint: device_fingerprint, | ||||||
|  | 							tfa_remember_me: true, | ||||||
|  | 							tfa_bypass_until: { gt: new Date() }, // Bypass still valid | ||||||
|  | 						}, | ||||||
|  | 					}); | ||||||
|  |  | ||||||
|  | 					if (remembered_session) { | ||||||
|  | 						// Device is remembered and bypass is still valid - skip TFA | ||||||
|  | 						// Continue with login below | ||||||
|  | 					} else { | ||||||
|  | 						// No valid bypass for this device - require TFA | ||||||
|  | 						return res.status(200).json({ | ||||||
|  | 							message: "TFA verification required", | ||||||
|  | 							requiresTfa: true, | ||||||
|  | 							username: user.username, | ||||||
|  | 						}); | ||||||
|  | 					} | ||||||
|  | 				} else { | ||||||
|  | 					// No device ID provided - require TFA | ||||||
|  | 					return res.status(200).json({ | ||||||
|  | 						message: "TFA verification required", | ||||||
|  | 						requiresTfa: true, | ||||||
|  | 						username: user.username, | ||||||
|  | 					}); | ||||||
|  | 				} | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 			// Update last login | 			// Update last login | ||||||
| @@ -807,7 +836,13 @@ router.post( | |||||||
| 			// Create session with access and refresh tokens | 			// Create session with access and refresh tokens | ||||||
| 			const ip_address = req.ip || req.connection.remoteAddress; | 			const ip_address = req.ip || req.connection.remoteAddress; | ||||||
| 			const user_agent = req.get("user-agent"); | 			const user_agent = req.get("user-agent"); | ||||||
| 			const session = await create_session(user.id, ip_address, user_agent); | 			const session = await create_session( | ||||||
|  | 				user.id, | ||||||
|  | 				ip_address, | ||||||
|  | 				user_agent, | ||||||
|  | 				false, | ||||||
|  | 				req, | ||||||
|  | 			); | ||||||
|  |  | ||||||
| 			res.json({ | 			res.json({ | ||||||
| 				message: "Login successful", | 				message: "Login successful", | ||||||
| @@ -841,8 +876,10 @@ router.post( | |||||||
| 		body("username").notEmpty().withMessage("Username is required"), | 		body("username").notEmpty().withMessage("Username is required"), | ||||||
| 		body("token") | 		body("token") | ||||||
| 			.isLength({ min: 6, max: 6 }) | 			.isLength({ min: 6, max: 6 }) | ||||||
| 			.withMessage("Token must be 6 digits"), | 			.withMessage("Token must be 6 characters"), | ||||||
| 		body("token").isNumeric().withMessage("Token must contain only numbers"), | 		body("token") | ||||||
|  | 			.matches(/^[A-Z0-9]{6}$/) | ||||||
|  | 			.withMessage("Token must be 6 alphanumeric characters"), | ||||||
| 		body("remember_me") | 		body("remember_me") | ||||||
| 			.optional() | 			.optional() | ||||||
| 			.isBoolean() | 			.isBoolean() | ||||||
|   | |||||||
| @@ -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,61 +228,65 @@ 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({ |  | ||||||
| 					where: { |  | ||||||
| 						host_id: host.id, |  | ||||||
| 						needs_update: true, |  | ||||||
| 					}, |  | ||||||
| 				}); |  | ||||||
|  |  | ||||||
| 				// Get total packages count for this host | 		const [updateCounts, totalCounts] = await Promise.all([ | ||||||
| 				const totalPackagesCount = await prisma.host_packages.count({ | 			// Get update counts for all hosts at once | ||||||
| 					where: { | 			prisma.host_packages.groupBy({ | ||||||
| 						host_id: host.id, | 				by: ["host_id"], | ||||||
| 					}, | 				where: { | ||||||
| 				}); | 					host_id: { in: hostIds }, | ||||||
|  | 					needs_update: true, | ||||||
| 				// Get the agent update interval setting for stale calculation | 				}, | ||||||
| 				const settings = await prisma.settings.findFirst(); | 				_count: { id: true }, | ||||||
| 				const updateIntervalMinutes = settings?.update_interval || 60; |  | ||||||
| 				const thresholdMinutes = updateIntervalMinutes * 2; |  | ||||||
|  |  | ||||||
| 				// Calculate effective status based on reporting interval |  | ||||||
| 				const isStale = moment(host.last_update).isBefore( |  | ||||||
| 					moment().subtract(thresholdMinutes, "minutes"), |  | ||||||
| 				); |  | ||||||
| 				let effectiveStatus = host.status; |  | ||||||
|  |  | ||||||
| 				// Override status if host hasn't reported within threshold |  | ||||||
| 				if (isStale && host.status === "active") { |  | ||||||
| 					effectiveStatus = "inactive"; |  | ||||||
| 				} |  | ||||||
|  |  | ||||||
| 				return { |  | ||||||
| 					...host, |  | ||||||
| 					updatesCount, |  | ||||||
| 					totalPackagesCount, |  | ||||||
| 					isStale, |  | ||||||
| 					effectiveStatus, |  | ||||||
| 				}; |  | ||||||
| 			}), | 			}), | ||||||
|  | 			// Get total counts for all hosts at once | ||||||
|  | 			prisma.host_packages.groupBy({ | ||||||
|  | 				by: ["host_id"], | ||||||
|  | 				where: { | ||||||
|  | 					host_id: { in: hostIds }, | ||||||
|  | 				}, | ||||||
|  | 				_count: { id: true }, | ||||||
|  | 			}), | ||||||
|  | 		]); | ||||||
|  |  | ||||||
|  | 		// Create lookup maps for O(1) access | ||||||
|  | 		const updateCountMap = new Map( | ||||||
|  | 			updateCounts.map((item) => [item.host_id, item._count.id]), | ||||||
| 		); | 		); | ||||||
|  | 		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 | ||||||
|  | 			const isStale = moment(host.last_update).isBefore( | ||||||
|  | 				moment().subtract(thresholdMinutes, "minutes"), | ||||||
|  | 			); | ||||||
|  | 			let effectiveStatus = host.status; | ||||||
|  |  | ||||||
|  | 			// Override status if host hasn't reported within threshold | ||||||
|  | 			if (isStale && host.status === "active") { | ||||||
|  | 				effectiveStatus = "inactive"; | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			return { | ||||||
|  | 				...host, | ||||||
|  | 				updatesCount, | ||||||
|  | 				totalPackagesCount, | ||||||
|  | 				isStale, | ||||||
|  | 				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; | ||||||
| @@ -572,6 +573,7 @@ router.post("/collect", async (req, res) => { | |||||||
| 							image_id: containerData.image_id || "unknown", | 							image_id: containerData.image_id || "unknown", | ||||||
| 							source: containerData.image_source || "docker-hub", | 							source: containerData.image_source || "docker-hub", | ||||||
| 							created_at: parseDate(containerData.created_at), | 							created_at: parseDate(containerData.created_at), | ||||||
|  | 							last_checked: now, | ||||||
| 							updated_at: now, | 							updated_at: now, | ||||||
| 						}, | 						}, | ||||||
| 					}); | 					}); | ||||||
| @@ -745,6 +747,497 @@ 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), | ||||||
|  | 							last_checked: now, | ||||||
|  | 							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) { | ||||||
|  | 				// If image has no digest, it's likely locally built - override source to "local" | ||||||
|  | 				const imageSource = | ||||||
|  | 					!imageData.digest || imageData.digest.trim() === "" | ||||||
|  | 						? "local" | ||||||
|  | 						: imageData.source || "docker-hub"; | ||||||
|  |  | ||||||
|  | 				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, | ||||||
|  | 						source: imageSource, // Update source in case it changed | ||||||
|  | 						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: imageSource, | ||||||
|  | 						created_at: parseDate(imageData.created_at), | ||||||
|  | 						last_checked: now, | ||||||
|  | 						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/volumes - Get all volumes with filters | ||||||
|  | router.get("/volumes", authenticateToken, async (req, res) => { | ||||||
|  | 	try { | ||||||
|  | 		const { driver, search, page = 1, limit = 50 } = req.query; | ||||||
|  |  | ||||||
|  | 		const where = {}; | ||||||
|  | 		if (driver) where.driver = driver; | ||||||
|  | 		if (search) { | ||||||
|  | 			where.OR = [{ name: { contains: search, mode: "insensitive" } }]; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10); | ||||||
|  | 		const take = parseInt(limit, 10); | ||||||
|  |  | ||||||
|  | 		const [volumes, total] = await Promise.all([ | ||||||
|  | 			prisma.docker_volumes.findMany({ | ||||||
|  | 				where, | ||||||
|  | 				include: { | ||||||
|  | 					hosts: { | ||||||
|  | 						select: { | ||||||
|  | 							id: true, | ||||||
|  | 							friendly_name: true, | ||||||
|  | 							hostname: true, | ||||||
|  | 							ip: true, | ||||||
|  | 						}, | ||||||
|  | 					}, | ||||||
|  | 				}, | ||||||
|  | 				orderBy: { updated_at: "desc" }, | ||||||
|  | 				skip, | ||||||
|  | 				take, | ||||||
|  | 			}), | ||||||
|  | 			prisma.docker_volumes.count({ where }), | ||||||
|  | 		]); | ||||||
|  |  | ||||||
|  | 		res.json( | ||||||
|  | 			convertBigIntToString({ | ||||||
|  | 				volumes, | ||||||
|  | 				pagination: { | ||||||
|  | 					page: parseInt(page, 10), | ||||||
|  | 					limit: parseInt(limit, 10), | ||||||
|  | 					total, | ||||||
|  | 					totalPages: Math.ceil(total / parseInt(limit, 10)), | ||||||
|  | 				}, | ||||||
|  | 			}), | ||||||
|  | 		); | ||||||
|  | 	} catch (error) { | ||||||
|  | 		console.error("Error fetching volumes:", error); | ||||||
|  | 		res.status(500).json({ error: "Failed to fetch volumes" }); | ||||||
|  | 	} | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // GET /api/v1/docker/volumes/:id - Get volume detail | ||||||
|  | router.get("/volumes/:id", authenticateToken, async (req, res) => { | ||||||
|  | 	try { | ||||||
|  | 		const { id } = req.params; | ||||||
|  |  | ||||||
|  | 		const volume = await prisma.docker_volumes.findUnique({ | ||||||
|  | 			where: { id }, | ||||||
|  | 			include: { | ||||||
|  | 				hosts: { | ||||||
|  | 					select: { | ||||||
|  | 						id: true, | ||||||
|  | 						friendly_name: true, | ||||||
|  | 						hostname: true, | ||||||
|  | 						ip: true, | ||||||
|  | 						os_type: true, | ||||||
|  | 						os_version: true, | ||||||
|  | 					}, | ||||||
|  | 				}, | ||||||
|  | 			}, | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		if (!volume) { | ||||||
|  | 			return res.status(404).json({ error: "Volume not found" }); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		res.json(convertBigIntToString({ volume })); | ||||||
|  | 	} catch (error) { | ||||||
|  | 		console.error("Error fetching volume detail:", error); | ||||||
|  | 		res.status(500).json({ error: "Failed to fetch volume detail" }); | ||||||
|  | 	} | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // GET /api/v1/docker/networks - Get all networks with filters | ||||||
|  | router.get("/networks", authenticateToken, async (req, res) => { | ||||||
|  | 	try { | ||||||
|  | 		const { driver, search, page = 1, limit = 50 } = req.query; | ||||||
|  |  | ||||||
|  | 		const where = {}; | ||||||
|  | 		if (driver) where.driver = driver; | ||||||
|  | 		if (search) { | ||||||
|  | 			where.OR = [{ name: { contains: search, mode: "insensitive" } }]; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10); | ||||||
|  | 		const take = parseInt(limit, 10); | ||||||
|  |  | ||||||
|  | 		const [networks, total] = await Promise.all([ | ||||||
|  | 			prisma.docker_networks.findMany({ | ||||||
|  | 				where, | ||||||
|  | 				include: { | ||||||
|  | 					hosts: { | ||||||
|  | 						select: { | ||||||
|  | 							id: true, | ||||||
|  | 							friendly_name: true, | ||||||
|  | 							hostname: true, | ||||||
|  | 							ip: true, | ||||||
|  | 						}, | ||||||
|  | 					}, | ||||||
|  | 				}, | ||||||
|  | 				orderBy: { updated_at: "desc" }, | ||||||
|  | 				skip, | ||||||
|  | 				take, | ||||||
|  | 			}), | ||||||
|  | 			prisma.docker_networks.count({ where }), | ||||||
|  | 		]); | ||||||
|  |  | ||||||
|  | 		res.json( | ||||||
|  | 			convertBigIntToString({ | ||||||
|  | 				networks, | ||||||
|  | 				pagination: { | ||||||
|  | 					page: parseInt(page, 10), | ||||||
|  | 					limit: parseInt(limit, 10), | ||||||
|  | 					total, | ||||||
|  | 					totalPages: Math.ceil(total / parseInt(limit, 10)), | ||||||
|  | 				}, | ||||||
|  | 			}), | ||||||
|  | 		); | ||||||
|  | 	} catch (error) { | ||||||
|  | 		console.error("Error fetching networks:", error); | ||||||
|  | 		res.status(500).json({ error: "Failed to fetch networks" }); | ||||||
|  | 	} | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // GET /api/v1/docker/networks/:id - Get network detail | ||||||
|  | router.get("/networks/:id", authenticateToken, async (req, res) => { | ||||||
|  | 	try { | ||||||
|  | 		const { id } = req.params; | ||||||
|  |  | ||||||
|  | 		const network = await prisma.docker_networks.findUnique({ | ||||||
|  | 			where: { id }, | ||||||
|  | 			include: { | ||||||
|  | 				hosts: { | ||||||
|  | 					select: { | ||||||
|  | 						id: true, | ||||||
|  | 						friendly_name: true, | ||||||
|  | 						hostname: true, | ||||||
|  | 						ip: true, | ||||||
|  | 						os_type: true, | ||||||
|  | 						os_version: true, | ||||||
|  | 					}, | ||||||
|  | 				}, | ||||||
|  | 			}, | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		if (!network) { | ||||||
|  | 			return res.status(404).json({ error: "Network not found" }); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		res.json(convertBigIntToString({ network })); | ||||||
|  | 	} catch (error) { | ||||||
|  | 		console.error("Error fetching network detail:", error); | ||||||
|  | 		res.status(500).json({ error: "Failed to fetch network detail" }); | ||||||
|  | 	} | ||||||
|  | }); | ||||||
|  |  | ||||||
| // 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 { | ||||||
| @@ -776,4 +1269,66 @@ router.get("/agent", async (_req, res) => { | |||||||
| 	} | 	} | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | // DELETE /api/v1/docker/volumes/:id - Delete a volume | ||||||
|  | router.delete("/volumes/:id", authenticateToken, async (req, res) => { | ||||||
|  | 	try { | ||||||
|  | 		const { id } = req.params; | ||||||
|  |  | ||||||
|  | 		// Check if volume exists | ||||||
|  | 		const volume = await prisma.docker_volumes.findUnique({ | ||||||
|  | 			where: { id }, | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		if (!volume) { | ||||||
|  | 			return res.status(404).json({ error: "Volume not found" }); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Delete the volume | ||||||
|  | 		await prisma.docker_volumes.delete({ | ||||||
|  | 			where: { id }, | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		console.log(`🗑️  Deleted volume: ${volume.name} (${id})`); | ||||||
|  |  | ||||||
|  | 		res.json({ | ||||||
|  | 			success: true, | ||||||
|  | 			message: `Volume ${volume.name} deleted successfully`, | ||||||
|  | 		}); | ||||||
|  | 	} catch (error) { | ||||||
|  | 		console.error("Error deleting volume:", error); | ||||||
|  | 		res.status(500).json({ error: "Failed to delete volume" }); | ||||||
|  | 	} | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // DELETE /api/v1/docker/networks/:id - Delete a network | ||||||
|  | router.delete("/networks/:id", authenticateToken, async (req, res) => { | ||||||
|  | 	try { | ||||||
|  | 		const { id } = req.params; | ||||||
|  |  | ||||||
|  | 		// Check if network exists | ||||||
|  | 		const network = await prisma.docker_networks.findUnique({ | ||||||
|  | 			where: { id }, | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		if (!network) { | ||||||
|  | 			return res.status(404).json({ error: "Network not found" }); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Delete the network | ||||||
|  | 		await prisma.docker_networks.delete({ | ||||||
|  | 			where: { id }, | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		console.log(`🗑️  Deleted network: ${network.name} (${id})`); | ||||||
|  |  | ||||||
|  | 		res.json({ | ||||||
|  | 			success: true, | ||||||
|  | 			message: `Network ${network.name} deleted successfully`, | ||||||
|  | 		}); | ||||||
|  | 	} catch (error) { | ||||||
|  | 		console.error("Error deleting network:", error); | ||||||
|  | 		res.status(500).json({ error: "Failed to delete network" }); | ||||||
|  | 	} | ||||||
|  | }); | ||||||
|  |  | ||||||
| module.exports = router; | module.exports = router; | ||||||
|   | |||||||
| @@ -24,7 +24,15 @@ router.get("/", authenticateToken, async (_req, res) => { | |||||||
| 			}, | 			}, | ||||||
| 		}); | 		}); | ||||||
|  |  | ||||||
| 		res.json(hostGroups); | 		// Transform the count field to match frontend expectations | ||||||
|  | 		const transformedGroups = hostGroups.map((group) => ({ | ||||||
|  | 			...group, | ||||||
|  | 			_count: { | ||||||
|  | 				hosts: group._count.host_group_memberships, | ||||||
|  | 			}, | ||||||
|  | 		})); | ||||||
|  |  | ||||||
|  | 		res.json(transformedGroups); | ||||||
| 	} catch (error) { | 	} catch (error) { | ||||||
| 		console.error("Error fetching host groups:", error); | 		console.error("Error fetching host groups:", error); | ||||||
| 		res.status(500).json({ error: "Failed to fetch host groups" }); | 		res.status(500).json({ error: "Failed to fetch host groups" }); | ||||||
|   | |||||||
| @@ -10,6 +10,7 @@ const { | |||||||
| 	requireManageHosts, | 	requireManageHosts, | ||||||
| 	requireManageSettings, | 	requireManageSettings, | ||||||
| } = require("../middleware/permissions"); | } = require("../middleware/permissions"); | ||||||
|  | const { queueManager, QUEUE_NAMES } = require("../services/automation"); | ||||||
|  |  | ||||||
| const router = express.Router(); | const router = express.Router(); | ||||||
| const prisma = getPrismaClient(); | const prisma = getPrismaClient(); | ||||||
| @@ -356,6 +357,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 +807,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 | ||||||
| @@ -1345,6 +1388,66 @@ router.delete( | |||||||
| 	}, | 	}, | ||||||
| ); | ); | ||||||
|  |  | ||||||
|  | // Force immediate report from agent | ||||||
|  | router.post( | ||||||
|  | 	"/:hostId/fetch-report", | ||||||
|  | 	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 the agent-commands queue | ||||||
|  | 			const queue = queueManager.queues[QUEUE_NAMES.AGENT_COMMANDS]; | ||||||
|  |  | ||||||
|  | 			if (!queue) { | ||||||
|  | 				return res.status(500).json({ | ||||||
|  | 					error: "Queue not available", | ||||||
|  | 				}); | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			// Add job to queue | ||||||
|  | 			const job = await queue.add( | ||||||
|  | 				"report_now", | ||||||
|  | 				{ | ||||||
|  | 					api_id: host.api_id, | ||||||
|  | 					type: "report_now", | ||||||
|  | 				}, | ||||||
|  | 				{ | ||||||
|  | 					attempts: 3, | ||||||
|  | 					backoff: { | ||||||
|  | 						type: "exponential", | ||||||
|  | 						delay: 2000, | ||||||
|  | 					}, | ||||||
|  | 				}, | ||||||
|  | 			); | ||||||
|  |  | ||||||
|  | 			res.json({ | ||||||
|  | 				success: true, | ||||||
|  | 				message: "Report fetch queued successfully", | ||||||
|  | 				jobId: job.id, | ||||||
|  | 				host: { | ||||||
|  | 					id: host.id, | ||||||
|  | 					friendlyName: host.friendly_name, | ||||||
|  | 					apiId: host.api_id, | ||||||
|  | 				}, | ||||||
|  | 			}); | ||||||
|  | 		} catch (error) { | ||||||
|  | 			console.error("Force fetch report error:", error); | ||||||
|  | 			res.status(500).json({ error: "Failed to fetch report" }); | ||||||
|  | 		} | ||||||
|  | 	}, | ||||||
|  | ); | ||||||
|  |  | ||||||
| // Toggle agent auto-update setting | // Toggle agent auto-update setting | ||||||
| router.patch( | router.patch( | ||||||
| 	"/:hostId/auto-update", | 	"/:hostId/auto-update", | ||||||
| @@ -1388,6 +1491,66 @@ 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 the agent-commands queue | ||||||
|  | 			const queue = queueManager.queues[QUEUE_NAMES.AGENT_COMMANDS]; | ||||||
|  |  | ||||||
|  | 			if (!queue) { | ||||||
|  | 				return res.status(500).json({ | ||||||
|  | 					error: "Queue not available", | ||||||
|  | 				}); | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			// Add job to queue | ||||||
|  | 			const job = 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", | ||||||
|  | 				jobId: job.id, | ||||||
|  | 				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 +1604,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 +1619,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}" | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										356
									
								
								backend/src/routes/integrationRoutes.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										356
									
								
								backend/src/routes/integrationRoutes.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,356 @@ | |||||||
|  | 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, | ||||||
|  | 			volumes, | ||||||
|  | 			networks, | ||||||
|  | 			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 volumesProcessed = 0; | ||||||
|  | 		let networksProcessed = 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 volumes | ||||||
|  | 		if (volumes && Array.isArray(volumes)) { | ||||||
|  | 			console.log(`[Docker Integration] Processing ${volumes.length} volumes`); | ||||||
|  | 			for (const volumeData of volumes) { | ||||||
|  | 				await prisma.docker_volumes.upsert({ | ||||||
|  | 					where: { | ||||||
|  | 						host_id_volume_id: { | ||||||
|  | 							host_id: host.id, | ||||||
|  | 							volume_id: volumeData.volume_id, | ||||||
|  | 						}, | ||||||
|  | 					}, | ||||||
|  | 					update: { | ||||||
|  | 						name: volumeData.name, | ||||||
|  | 						driver: volumeData.driver || "local", | ||||||
|  | 						mountpoint: volumeData.mountpoint || null, | ||||||
|  | 						renderer: volumeData.renderer || null, | ||||||
|  | 						scope: volumeData.scope || "local", | ||||||
|  | 						labels: volumeData.labels || null, | ||||||
|  | 						options: volumeData.options || null, | ||||||
|  | 						size_bytes: volumeData.size_bytes | ||||||
|  | 							? BigInt(volumeData.size_bytes) | ||||||
|  | 							: null, | ||||||
|  | 						ref_count: volumeData.ref_count || 0, | ||||||
|  | 						updated_at: now, | ||||||
|  | 						last_checked: now, | ||||||
|  | 					}, | ||||||
|  | 					create: { | ||||||
|  | 						id: uuidv4(), | ||||||
|  | 						host_id: host.id, | ||||||
|  | 						volume_id: volumeData.volume_id, | ||||||
|  | 						name: volumeData.name, | ||||||
|  | 						driver: volumeData.driver || "local", | ||||||
|  | 						mountpoint: volumeData.mountpoint || null, | ||||||
|  | 						renderer: volumeData.renderer || null, | ||||||
|  | 						scope: volumeData.scope || "local", | ||||||
|  | 						labels: volumeData.labels || null, | ||||||
|  | 						options: volumeData.options || null, | ||||||
|  | 						size_bytes: volumeData.size_bytes | ||||||
|  | 							? BigInt(volumeData.size_bytes) | ||||||
|  | 							: null, | ||||||
|  | 						ref_count: volumeData.ref_count || 0, | ||||||
|  | 						created_at: parseDate(volumeData.created_at), | ||||||
|  | 						updated_at: now, | ||||||
|  | 					}, | ||||||
|  | 				}); | ||||||
|  | 				volumesProcessed++; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Process networks | ||||||
|  | 		if (networks && Array.isArray(networks)) { | ||||||
|  | 			console.log( | ||||||
|  | 				`[Docker Integration] Processing ${networks.length} networks`, | ||||||
|  | 			); | ||||||
|  | 			for (const networkData of networks) { | ||||||
|  | 				await prisma.docker_networks.upsert({ | ||||||
|  | 					where: { | ||||||
|  | 						host_id_network_id: { | ||||||
|  | 							host_id: host.id, | ||||||
|  | 							network_id: networkData.network_id, | ||||||
|  | 						}, | ||||||
|  | 					}, | ||||||
|  | 					update: { | ||||||
|  | 						name: networkData.name, | ||||||
|  | 						driver: networkData.driver, | ||||||
|  | 						scope: networkData.scope || "local", | ||||||
|  | 						ipv6_enabled: networkData.ipv6_enabled || false, | ||||||
|  | 						internal: networkData.internal || false, | ||||||
|  | 						attachable: | ||||||
|  | 							networkData.attachable !== undefined | ||||||
|  | 								? networkData.attachable | ||||||
|  | 								: true, | ||||||
|  | 						ingress: networkData.ingress || false, | ||||||
|  | 						config_only: networkData.config_only || false, | ||||||
|  | 						labels: networkData.labels || null, | ||||||
|  | 						ipam: networkData.ipam || null, | ||||||
|  | 						container_count: networkData.container_count || 0, | ||||||
|  | 						updated_at: now, | ||||||
|  | 						last_checked: now, | ||||||
|  | 					}, | ||||||
|  | 					create: { | ||||||
|  | 						id: uuidv4(), | ||||||
|  | 						host_id: host.id, | ||||||
|  | 						network_id: networkData.network_id, | ||||||
|  | 						name: networkData.name, | ||||||
|  | 						driver: networkData.driver, | ||||||
|  | 						scope: networkData.scope || "local", | ||||||
|  | 						ipv6_enabled: networkData.ipv6_enabled || false, | ||||||
|  | 						internal: networkData.internal || false, | ||||||
|  | 						attachable: | ||||||
|  | 							networkData.attachable !== undefined | ||||||
|  | 								? networkData.attachable | ||||||
|  | 								: true, | ||||||
|  | 						ingress: networkData.ingress || false, | ||||||
|  | 						config_only: networkData.config_only || false, | ||||||
|  | 						labels: networkData.labels || null, | ||||||
|  | 						ipam: networkData.ipam || null, | ||||||
|  | 						container_count: networkData.container_count || 0, | ||||||
|  | 						created_at: networkData.created_at | ||||||
|  | 							? parseDate(networkData.created_at) | ||||||
|  | 							: null, | ||||||
|  | 						updated_at: now, | ||||||
|  | 					}, | ||||||
|  | 				}); | ||||||
|  | 				networksProcessed++; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// 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, ${volumesProcessed} volumes, ${networksProcessed} networks, ${updatesProcessed} updates`, | ||||||
|  | 		); | ||||||
|  |  | ||||||
|  | 		res.json({ | ||||||
|  | 			message: "Docker data collected successfully", | ||||||
|  | 			containers_received: containersProcessed, | ||||||
|  | 			images_received: imagesProcessed, | ||||||
|  | 			volumes_received: volumesProcessed, | ||||||
|  | 			networks_received: networksProcessed, | ||||||
|  | 			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,74 +101,107 @@ 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({ | ||||||
| 						where: { | 					by: ["package_id"], | ||||||
| 							...hostWhere, | 					where: { | ||||||
| 							needs_update: true, | 						package_id: { in: packageIds }, | ||||||
| 						}, | 						needs_update: true, | ||||||
| 					}), | 						...(host ? { host_id: host } : {}), | ||||||
| 					prisma.host_packages.count({ |  | ||||||
| 						where: { |  | ||||||
| 							...hostWhere, |  | ||||||
| 							needs_update: true, |  | ||||||
| 							is_security_update: true, |  | ||||||
| 						}, |  | ||||||
| 					}), |  | ||||||
| 					prisma.host_packages.findMany({ |  | ||||||
| 						where: { |  | ||||||
| 							...hostWhere, |  | ||||||
| 							// If host filter is specified, include all packages for that host |  | ||||||
| 							// Otherwise, only include packages that need updates |  | ||||||
| 							...(host ? {} : { needs_update: true }), |  | ||||||
| 						}, |  | ||||||
| 						select: { |  | ||||||
| 							hosts: { |  | ||||||
| 								select: { |  | ||||||
| 									id: true, |  | ||||||
| 									friendly_name: true, |  | ||||||
| 									hostname: true, |  | ||||||
| 									os_type: true, |  | ||||||
| 								}, |  | ||||||
| 							}, |  | ||||||
| 							current_version: true, |  | ||||||
| 							available_version: true, |  | ||||||
| 							needs_update: true, |  | ||||||
| 							is_security_update: true, |  | ||||||
| 						}, |  | ||||||
| 						take: 10, // Limit to first 10 for performance |  | ||||||
| 					}), |  | ||||||
| 				]); |  | ||||||
|  |  | ||||||
| 				return { |  | ||||||
| 					...pkg, |  | ||||||
| 					packageHostsCount: pkg._count.host_packages, |  | ||||||
| 					packageHosts: packageHosts.map((hp) => ({ |  | ||||||
| 						hostId: hp.hosts.id, |  | ||||||
| 						friendlyName: hp.hosts.friendly_name, |  | ||||||
| 						osType: hp.hosts.os_type, |  | ||||||
| 						currentVersion: hp.current_version, |  | ||||||
| 						availableVersion: hp.available_version, |  | ||||||
| 						needsUpdate: hp.needs_update, |  | ||||||
| 						isSecurityUpdate: hp.is_security_update, |  | ||||||
| 					})), |  | ||||||
| 					stats: { |  | ||||||
| 						totalInstalls: pkg._count.host_packages, |  | ||||||
| 						updatesNeeded: updatesCount, |  | ||||||
| 						securityUpdates: securityCount, |  | ||||||
| 					}, | 					}, | ||||||
| 				}; | 					_count: { id: true }, | ||||||
| 			}), | 				}), | ||||||
|  | 				// Batch count all packages with security updates | ||||||
|  | 				prisma.host_packages.groupBy({ | ||||||
|  | 					by: ["package_id"], | ||||||
|  | 					where: { | ||||||
|  | 						package_id: { in: packageIds }, | ||||||
|  | 						needs_update: true, | ||||||
|  | 						is_security_update: true, | ||||||
|  | 						...(host ? { host_id: host } : {}), | ||||||
|  | 					}, | ||||||
|  | 					_count: { id: true }, | ||||||
|  | 				}), | ||||||
|  | 				// Batch fetch all host data for packages | ||||||
|  | 				prisma.host_packages.findMany({ | ||||||
|  | 					where: { | ||||||
|  | 						package_id: { in: packageIds }, | ||||||
|  | 						...(host ? { host_id: host } : { needs_update: true }), | ||||||
|  | 					}, | ||||||
|  | 					select: { | ||||||
|  | 						package_id: true, | ||||||
|  | 						hosts: { | ||||||
|  | 							select: { | ||||||
|  | 								id: true, | ||||||
|  | 								friendly_name: true, | ||||||
|  | 								hostname: true, | ||||||
|  | 								os_type: true, | ||||||
|  | 							}, | ||||||
|  | 						}, | ||||||
|  | 						current_version: true, | ||||||
|  | 						available_version: true, | ||||||
|  | 						needs_update: true, | ||||||
|  | 						is_security_update: true, | ||||||
|  | 					}, | ||||||
|  | 					// Limit to first 10 per package | ||||||
|  | 					take: 100, // Increased from package-based limit | ||||||
|  | 				}), | ||||||
|  | 			]); | ||||||
|  |  | ||||||
|  | 		// Create lookup maps for O(1) access | ||||||
|  | 		const updatesCountMap = new Map( | ||||||
|  | 			allUpdatesCounts.map((item) => [item.package_id, item._count.id]), | ||||||
| 		); | 		); | ||||||
|  | 		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, | ||||||
|  | 				friendlyName: hp.hosts.friendly_name, | ||||||
|  | 				osType: hp.hosts.os_type, | ||||||
|  | 				currentVersion: hp.current_version, | ||||||
|  | 				availableVersion: hp.available_version, | ||||||
|  | 				needsUpdate: hp.needs_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: { | ||||||
|  | 					totalInstalls: pkg._count.host_packages, | ||||||
|  | 					updatesNeeded: updatesCount, | ||||||
|  | 					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, | ||||||
|   | |||||||
| @@ -261,8 +261,10 @@ router.post( | |||||||
| 		body("username").notEmpty().withMessage("Username is required"), | 		body("username").notEmpty().withMessage("Username is required"), | ||||||
| 		body("token") | 		body("token") | ||||||
| 			.isLength({ min: 6, max: 6 }) | 			.isLength({ min: 6, max: 6 }) | ||||||
| 			.withMessage("Token must be 6 digits"), | 			.withMessage("Token must be 6 characters"), | ||||||
| 		body("token").isNumeric().withMessage("Token must contain only numbers"), | 		body("token") | ||||||
|  | 			.matches(/^[A-Z0-9]{6}$/) | ||||||
|  | 			.withMessage("Token must be 6 alphanumeric characters"), | ||||||
| 	], | 	], | ||||||
| 	async (req, res) => { | 	async (req, res) => { | ||||||
| 		try { | 		try { | ||||||
|   | |||||||
							
								
								
									
										105
									
								
								backend/src/routes/userPreferencesRoutes.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								backend/src/routes/userPreferencesRoutes.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,105 @@ | |||||||
|  | const express = require("express"); | ||||||
|  | const { getPrismaClient } = require("../config/prisma"); | ||||||
|  | const { authenticateToken } = require("../middleware/auth"); | ||||||
|  |  | ||||||
|  | const router = express.Router(); | ||||||
|  | const prisma = getPrismaClient(); | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * GET /api/v1/user/preferences | ||||||
|  |  * Get current user's preferences (theme and color theme) | ||||||
|  |  */ | ||||||
|  | router.get("/", authenticateToken, async (req, res) => { | ||||||
|  | 	try { | ||||||
|  | 		const userId = req.user.id; | ||||||
|  |  | ||||||
|  | 		const user = await prisma.users.findUnique({ | ||||||
|  | 			where: { id: userId }, | ||||||
|  | 			select: { | ||||||
|  | 				theme_preference: true, | ||||||
|  | 				color_theme: true, | ||||||
|  | 			}, | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		if (!user) { | ||||||
|  | 			return res.status(404).json({ error: "User not found" }); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		res.json({ | ||||||
|  | 			theme_preference: user.theme_preference || "dark", | ||||||
|  | 			color_theme: user.color_theme || "cyber_blue", | ||||||
|  | 		}); | ||||||
|  | 	} catch (error) { | ||||||
|  | 		console.error("Error fetching user preferences:", error); | ||||||
|  | 		res.status(500).json({ error: "Failed to fetch user preferences" }); | ||||||
|  | 	} | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * PATCH /api/v1/user/preferences | ||||||
|  |  * Update current user's preferences | ||||||
|  |  */ | ||||||
|  | router.patch("/", authenticateToken, async (req, res) => { | ||||||
|  | 	try { | ||||||
|  | 		const userId = req.user.id; | ||||||
|  | 		const { theme_preference, color_theme } = req.body; | ||||||
|  |  | ||||||
|  | 		// Validate inputs | ||||||
|  | 		const updateData = {}; | ||||||
|  | 		if (theme_preference !== undefined) { | ||||||
|  | 			if (!["light", "dark"].includes(theme_preference)) { | ||||||
|  | 				return res.status(400).json({ | ||||||
|  | 					error: "Invalid theme preference. Must be 'light' or 'dark'", | ||||||
|  | 				}); | ||||||
|  | 			} | ||||||
|  | 			updateData.theme_preference = theme_preference; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if (color_theme !== undefined) { | ||||||
|  | 			const validColorThemes = [ | ||||||
|  | 				"default", | ||||||
|  | 				"cyber_blue", | ||||||
|  | 				"neon_purple", | ||||||
|  | 				"matrix_green", | ||||||
|  | 				"ocean_blue", | ||||||
|  | 				"sunset_gradient", | ||||||
|  | 			]; | ||||||
|  | 			if (!validColorThemes.includes(color_theme)) { | ||||||
|  | 				return res.status(400).json({ | ||||||
|  | 					error: `Invalid color theme. Must be one of: ${validColorThemes.join(", ")}`, | ||||||
|  | 				}); | ||||||
|  | 			} | ||||||
|  | 			updateData.color_theme = color_theme; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if (Object.keys(updateData).length === 0) { | ||||||
|  | 			return res | ||||||
|  | 				.status(400) | ||||||
|  | 				.json({ error: "No preferences provided to update" }); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		updateData.updated_at = new Date(); | ||||||
|  |  | ||||||
|  | 		const updatedUser = await prisma.users.update({ | ||||||
|  | 			where: { id: userId }, | ||||||
|  | 			data: updateData, | ||||||
|  | 			select: { | ||||||
|  | 				theme_preference: true, | ||||||
|  | 				color_theme: true, | ||||||
|  | 			}, | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		res.json({ | ||||||
|  | 			message: "Preferences updated successfully", | ||||||
|  | 			preferences: { | ||||||
|  | 				theme_preference: updatedUser.theme_preference, | ||||||
|  | 				color_theme: updatedUser.color_theme, | ||||||
|  | 			}, | ||||||
|  | 		}); | ||||||
|  | 	} catch (error) { | ||||||
|  | 		console.error("Error updating user preferences:", error); | ||||||
|  | 		res.status(500).json({ error: "Failed to update user preferences" }); | ||||||
|  | 	} | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | module.exports = router; | ||||||
| @@ -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,11 @@ 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 userPreferencesRoutes = require("./routes/userPreferencesRoutes"); | ||||||
| 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"); | ||||||
| @@ -471,8 +474,11 @@ 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); | ||||||
|  | app.use(`/api/${apiVersion}/user/preferences`, userPreferencesRoutes); | ||||||
|  |  | ||||||
| // 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 +1204,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; | ||||||
|   | |||||||
							
								
								
									
										343
									
								
								backend/src/services/automation/dockerImageUpdateCheck.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										343
									
								
								backend/src/services/automation/dockerImageUpdateCheck.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,343 @@ | |||||||
|  | const { prisma } = require("./shared/prisma"); | ||||||
|  | const https = require("node:https"); | ||||||
|  | const http = require("node:http"); | ||||||
|  | const { v4: uuidv4 } = require("uuid"); | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Docker Image Update Check Automation | ||||||
|  |  * Checks for Docker image updates by comparing local digests with remote registry digests | ||||||
|  |  */ | ||||||
|  | class DockerImageUpdateCheck { | ||||||
|  | 	constructor(queueManager) { | ||||||
|  | 		this.queueManager = queueManager; | ||||||
|  | 		this.queueName = "docker-image-update-check"; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	/** | ||||||
|  | 	 * Get remote digest from Docker registry using HEAD request | ||||||
|  | 	 * Supports Docker Hub, GHCR, and other OCI-compliant registries | ||||||
|  | 	 */ | ||||||
|  | 	async getRemoteDigest(imageName, tag = "latest") { | ||||||
|  | 		return new Promise((resolve, reject) => { | ||||||
|  | 			// Parse image name to determine registry | ||||||
|  | 			const registryInfo = this.parseImageName(imageName); | ||||||
|  |  | ||||||
|  | 			// Construct manifest URL | ||||||
|  | 			const manifestPath = `/v2/${registryInfo.repository}/manifests/${tag}`; | ||||||
|  | 			const options = { | ||||||
|  | 				hostname: registryInfo.registry, | ||||||
|  | 				path: manifestPath, | ||||||
|  | 				method: "HEAD", | ||||||
|  | 				headers: { | ||||||
|  | 					Accept: | ||||||
|  | 						"application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json", | ||||||
|  | 					"User-Agent": "PatchMon/1.0", | ||||||
|  | 				}, | ||||||
|  | 			}; | ||||||
|  |  | ||||||
|  | 			// Add authentication token for Docker Hub if needed | ||||||
|  | 			if ( | ||||||
|  | 				registryInfo.registry === "registry-1.docker.io" && | ||||||
|  | 				registryInfo.isPublic | ||||||
|  | 			) { | ||||||
|  | 				// For anonymous public images, we may need to get an auth token first | ||||||
|  | 				// For now, try without auth (works for public images) | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			// Choose HTTP or HTTPS | ||||||
|  | 			const client = registryInfo.isSecure ? https : http; | ||||||
|  |  | ||||||
|  | 			const req = client.request(options, (res) => { | ||||||
|  | 				if (res.statusCode === 401 || res.statusCode === 403) { | ||||||
|  | 					// Authentication required - skip for now (would need to implement auth) | ||||||
|  | 					return reject( | ||||||
|  | 						new Error(`Authentication required for ${imageName}:${tag}`), | ||||||
|  | 					); | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				if (res.statusCode !== 200) { | ||||||
|  | 					return reject( | ||||||
|  | 						new Error( | ||||||
|  | 							`Registry returned status ${res.statusCode} for ${imageName}:${tag}`, | ||||||
|  | 						), | ||||||
|  | 					); | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				// Get digest from Docker-Content-Digest header | ||||||
|  | 				const digest = res.headers["docker-content-digest"]; | ||||||
|  | 				if (!digest) { | ||||||
|  | 					return reject( | ||||||
|  | 						new Error( | ||||||
|  | 							`No Docker-Content-Digest header for ${imageName}:${tag}`, | ||||||
|  | 						), | ||||||
|  | 					); | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				// Clean up digest (remove sha256: prefix if present) | ||||||
|  | 				const cleanDigest = digest.startsWith("sha256:") | ||||||
|  | 					? digest.substring(7) | ||||||
|  | 					: digest; | ||||||
|  | 				resolve(cleanDigest); | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			req.on("error", (error) => { | ||||||
|  | 				reject(error); | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			req.setTimeout(10000, () => { | ||||||
|  | 				req.destroy(); | ||||||
|  | 				reject(new Error(`Timeout getting digest for ${imageName}:${tag}`)); | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			req.end(); | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	/** | ||||||
|  | 	 * Parse image name to extract registry, repository, and determine if secure | ||||||
|  | 	 */ | ||||||
|  | 	parseImageName(imageName) { | ||||||
|  | 		let registry = "registry-1.docker.io"; | ||||||
|  | 		let repository = imageName; | ||||||
|  | 		const isSecure = true; | ||||||
|  | 		let isPublic = true; | ||||||
|  |  | ||||||
|  | 		// Handle explicit registries (ghcr.io, quay.io, etc.) | ||||||
|  | 		if (imageName.includes("/")) { | ||||||
|  | 			const parts = imageName.split("/"); | ||||||
|  | 			const firstPart = parts[0]; | ||||||
|  |  | ||||||
|  | 			// Check for known registries | ||||||
|  | 			if (firstPart.includes(".") || firstPart === "localhost") { | ||||||
|  | 				registry = firstPart; | ||||||
|  | 				repository = parts.slice(1).join("/"); | ||||||
|  | 				isPublic = false; // Assume private registries need auth for now | ||||||
|  | 			} else { | ||||||
|  | 				// Docker Hub - registry-1.docker.io | ||||||
|  | 				repository = imageName; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Docker Hub official images (no namespace) | ||||||
|  | 		if (!repository.includes("/")) { | ||||||
|  | 			repository = `library/${repository}`; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		return { | ||||||
|  | 			registry, | ||||||
|  | 			repository, | ||||||
|  | 			isSecure, | ||||||
|  | 			isPublic, | ||||||
|  | 		}; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	/** | ||||||
|  | 	 * Process Docker image update check job | ||||||
|  | 	 */ | ||||||
|  | 	async process(_job) { | ||||||
|  | 		const startTime = Date.now(); | ||||||
|  | 		console.log("🐳 Starting Docker image update check..."); | ||||||
|  |  | ||||||
|  | 		try { | ||||||
|  | 			// Get all Docker images that have a digest and repository | ||||||
|  | 			const images = await prisma.docker_images.findMany({ | ||||||
|  | 				where: { | ||||||
|  | 					digest: { | ||||||
|  | 						not: null, | ||||||
|  | 					}, | ||||||
|  | 					repository: { | ||||||
|  | 						not: null, | ||||||
|  | 					}, | ||||||
|  | 				}, | ||||||
|  | 				include: { | ||||||
|  | 					docker_image_updates: true, | ||||||
|  | 				}, | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			console.log(`📦 Found ${images.length} images to check for updates`); | ||||||
|  |  | ||||||
|  | 			let checkedCount = 0; | ||||||
|  | 			let updateCount = 0; | ||||||
|  | 			let errorCount = 0; | ||||||
|  | 			const errors = []; | ||||||
|  |  | ||||||
|  | 			// Process images in batches to avoid overwhelming the API | ||||||
|  | 			const batchSize = 10; | ||||||
|  | 			for (let i = 0; i < images.length; i += batchSize) { | ||||||
|  | 				const batch = images.slice(i, i + batchSize); | ||||||
|  |  | ||||||
|  | 				// Process batch concurrently with Promise.allSettled for error tolerance | ||||||
|  | 				const _results = await Promise.allSettled( | ||||||
|  | 					batch.map(async (image) => { | ||||||
|  | 						try { | ||||||
|  | 							checkedCount++; | ||||||
|  |  | ||||||
|  | 							// Skip local images (no digest means they're local) | ||||||
|  | 							if (!image.digest || image.digest.trim() === "") { | ||||||
|  | 								return { image, skipped: true, reason: "No digest" }; | ||||||
|  | 							} | ||||||
|  |  | ||||||
|  | 							// Get clean digest (remove sha256: prefix if present) | ||||||
|  | 							const localDigest = image.digest.startsWith("sha256:") | ||||||
|  | 								? image.digest.substring(7) | ||||||
|  | 								: image.digest; | ||||||
|  |  | ||||||
|  | 							// Get remote digest from registry | ||||||
|  | 							const remoteDigest = await this.getRemoteDigest( | ||||||
|  | 								image.repository, | ||||||
|  | 								image.tag || "latest", | ||||||
|  | 							); | ||||||
|  |  | ||||||
|  | 							// Compare digests | ||||||
|  | 							if (localDigest !== remoteDigest) { | ||||||
|  | 								console.log( | ||||||
|  | 									`🔄 Update found: ${image.repository}:${image.tag} (local: ${localDigest.substring(0, 12)}..., remote: ${remoteDigest.substring(0, 12)}...)`, | ||||||
|  | 								); | ||||||
|  |  | ||||||
|  | 								// Store digest info in changelog_url field as JSON | ||||||
|  | 								const digestInfo = JSON.stringify({ | ||||||
|  | 									method: "digest_comparison", | ||||||
|  | 									current_digest: localDigest, | ||||||
|  | 									available_digest: remoteDigest, | ||||||
|  | 									checked_at: new Date().toISOString(), | ||||||
|  | 								}); | ||||||
|  |  | ||||||
|  | 								// Upsert the update record | ||||||
|  | 								await prisma.docker_image_updates.upsert({ | ||||||
|  | 									where: { | ||||||
|  | 										image_id_available_tag: { | ||||||
|  | 											image_id: image.id, | ||||||
|  | 											available_tag: image.tag || "latest", | ||||||
|  | 										}, | ||||||
|  | 									}, | ||||||
|  | 									update: { | ||||||
|  | 										updated_at: new Date(), | ||||||
|  | 										changelog_url: digestInfo, | ||||||
|  | 										severity: "digest_changed", | ||||||
|  | 									}, | ||||||
|  | 									create: { | ||||||
|  | 										id: uuidv4(), | ||||||
|  | 										image_id: image.id, | ||||||
|  | 										current_tag: image.tag || "latest", | ||||||
|  | 										available_tag: image.tag || "latest", | ||||||
|  | 										severity: "digest_changed", | ||||||
|  | 										changelog_url: digestInfo, | ||||||
|  | 										updated_at: new Date(), | ||||||
|  | 									}, | ||||||
|  | 								}); | ||||||
|  |  | ||||||
|  | 								// Update last_checked timestamp on image | ||||||
|  | 								await prisma.docker_images.update({ | ||||||
|  | 									where: { id: image.id }, | ||||||
|  | 									data: { last_checked: new Date() }, | ||||||
|  | 								}); | ||||||
|  |  | ||||||
|  | 								updateCount++; | ||||||
|  | 								return { image, updated: true }; | ||||||
|  | 							} else { | ||||||
|  | 								// No update - still update last_checked | ||||||
|  | 								await prisma.docker_images.update({ | ||||||
|  | 									where: { id: image.id }, | ||||||
|  | 									data: { last_checked: new Date() }, | ||||||
|  | 								}); | ||||||
|  |  | ||||||
|  | 								// Remove existing update record if digest matches now | ||||||
|  | 								const existingUpdate = image.docker_image_updates?.find( | ||||||
|  | 									(u) => u.available_tag === (image.tag || "latest"), | ||||||
|  | 								); | ||||||
|  | 								if (existingUpdate) { | ||||||
|  | 									await prisma.docker_image_updates.delete({ | ||||||
|  | 										where: { id: existingUpdate.id }, | ||||||
|  | 									}); | ||||||
|  | 								} | ||||||
|  |  | ||||||
|  | 								return { image, updated: false }; | ||||||
|  | 							} | ||||||
|  | 						} catch (error) { | ||||||
|  | 							errorCount++; | ||||||
|  | 							const errorMsg = `Error checking ${image.repository}:${image.tag}: ${error.message}`; | ||||||
|  | 							errors.push(errorMsg); | ||||||
|  | 							console.error(`❌ ${errorMsg}`); | ||||||
|  |  | ||||||
|  | 							// Still update last_checked even on error | ||||||
|  | 							try { | ||||||
|  | 								await prisma.docker_images.update({ | ||||||
|  | 									where: { id: image.id }, | ||||||
|  | 									data: { last_checked: new Date() }, | ||||||
|  | 								}); | ||||||
|  | 							} catch (_updateError) { | ||||||
|  | 								// Ignore update errors | ||||||
|  | 							} | ||||||
|  |  | ||||||
|  | 							return { image, error: error.message }; | ||||||
|  | 						} | ||||||
|  | 					}), | ||||||
|  | 				); | ||||||
|  |  | ||||||
|  | 				// Log batch progress | ||||||
|  | 				if (i + batchSize < images.length) { | ||||||
|  | 					console.log( | ||||||
|  | 						`⏳ Processed ${Math.min(i + batchSize, images.length)}/${images.length} images...`, | ||||||
|  | 					); | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				// Small delay between batches to be respectful to registries | ||||||
|  | 				if (i + batchSize < images.length) { | ||||||
|  | 					await new Promise((resolve) => setTimeout(resolve, 500)); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			const executionTime = Date.now() - startTime; | ||||||
|  | 			console.log( | ||||||
|  | 				`✅ Docker image update check completed in ${executionTime}ms - Checked: ${checkedCount}, Updates: ${updateCount}, Errors: ${errorCount}`, | ||||||
|  | 			); | ||||||
|  |  | ||||||
|  | 			return { | ||||||
|  | 				success: true, | ||||||
|  | 				checked: checkedCount, | ||||||
|  | 				updates: updateCount, | ||||||
|  | 				errors: errorCount, | ||||||
|  | 				executionTime, | ||||||
|  | 				errorDetails: errors, | ||||||
|  | 			}; | ||||||
|  | 		} catch (error) { | ||||||
|  | 			const executionTime = Date.now() - startTime; | ||||||
|  | 			console.error( | ||||||
|  | 				`❌ Docker image update check failed after ${executionTime}ms:`, | ||||||
|  | 				error.message, | ||||||
|  | 			); | ||||||
|  | 			throw error; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	/** | ||||||
|  | 	 * Schedule recurring Docker image update check (daily at 2 AM) | ||||||
|  | 	 */ | ||||||
|  | 	async schedule() { | ||||||
|  | 		const job = await this.queueManager.queues[this.queueName].add( | ||||||
|  | 			"docker-image-update-check", | ||||||
|  | 			{}, | ||||||
|  | 			{ | ||||||
|  | 				repeat: { cron: "0 2 * * *" }, // Daily at 2 AM | ||||||
|  | 				jobId: "docker-image-update-check-recurring", | ||||||
|  | 			}, | ||||||
|  | 		); | ||||||
|  | 		console.log("✅ Docker image update check scheduled"); | ||||||
|  | 		return job; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	/** | ||||||
|  | 	 * Trigger manual Docker image update check | ||||||
|  | 	 */ | ||||||
|  | 	async triggerManual() { | ||||||
|  | 		const job = await this.queueManager.queues[this.queueName].add( | ||||||
|  | 			"docker-image-update-check-manual", | ||||||
|  | 			{}, | ||||||
|  | 			{ priority: 1 }, | ||||||
|  | 		); | ||||||
|  | 		console.log("✅ Manual Docker image update check triggered"); | ||||||
|  | 		return job; | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | module.exports = DockerImageUpdateCheck; | ||||||
							
								
								
									
										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 = | ||||||
|   | |||||||
| @@ -2,12 +2,16 @@ const { Queue, Worker } = require("bullmq"); | |||||||
| const { redis, redisConnection } = require("./shared/redis"); | const { redis, redisConnection } = require("./shared/redis"); | ||||||
| const { prisma } = require("./shared/prisma"); | const { prisma } = require("./shared/prisma"); | ||||||
| const agentWs = require("../agentWs"); | const agentWs = require("../agentWs"); | ||||||
|  | const { v4: uuidv4 } = require("uuid"); | ||||||
|  |  | ||||||
| // Import automation classes | // Import automation classes | ||||||
| const GitHubUpdateCheck = require("./githubUpdateCheck"); | 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 DockerImageUpdateCheck = require("./dockerImageUpdateCheck"); | ||||||
|  | const MetricsReporting = require("./metricsReporting"); | ||||||
|  |  | ||||||
| // Queue names | // Queue names | ||||||
| const QUEUE_NAMES = { | const QUEUE_NAMES = { | ||||||
| @@ -15,6 +19,9 @@ 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", | ||||||
|  | 	DOCKER_IMAGE_UPDATE_CHECK: "docker-image-update-check", | ||||||
|  | 	METRICS_REPORTING: "metrics-reporting", | ||||||
| 	AGENT_COMMANDS: "agent-commands", | 	AGENT_COMMANDS: "agent-commands", | ||||||
| }; | }; | ||||||
|  |  | ||||||
| @@ -91,6 +98,13 @@ 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.DOCKER_IMAGE_UPDATE_CHECK] = | ||||||
|  | 			new DockerImageUpdateCheck(this); | ||||||
|  | 		this.automations[QUEUE_NAMES.METRICS_REPORTING] = new MetricsReporting( | ||||||
|  | 			this, | ||||||
|  | 		); | ||||||
|  |  | ||||||
| 		console.log("✅ All automation classes initialized"); | 		console.log("✅ All automation classes initialized"); | ||||||
| 	} | 	} | ||||||
| @@ -149,6 +163,33 @@ 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, | ||||||
|  | 		); | ||||||
|  |  | ||||||
|  | 		// Docker Image Update Check Worker | ||||||
|  | 		this.workers[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK] = new Worker( | ||||||
|  | 			QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK, | ||||||
|  | 			this.automations[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK].process.bind( | ||||||
|  | 				this.automations[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK], | ||||||
|  | 			), | ||||||
|  | 			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, | ||||||
| @@ -156,15 +197,87 @@ class QueueManager { | |||||||
| 				const { api_id, type } = job.data; | 				const { api_id, type } = job.data; | ||||||
| 				console.log(`Processing agent command: ${type} for ${api_id}`); | 				console.log(`Processing agent command: ${type} for ${api_id}`); | ||||||
|  |  | ||||||
| 				// Send command via WebSocket based on type | 				// Log job to job_history | ||||||
| 				if (type === "report_now") { | 				let historyRecord = null; | ||||||
| 					agentWs.pushReportNow(api_id); | 				try { | ||||||
| 				} else if (type === "settings_update") { | 					const host = await prisma.hosts.findUnique({ | ||||||
| 					// For settings update, we need additional data | 						where: { api_id }, | ||||||
| 					const { update_interval } = job.data; | 						select: { id: true }, | ||||||
| 					agentWs.pushSettingsUpdate(api_id, update_interval); | 					}); | ||||||
| 				} else { |  | ||||||
| 					console.error(`Unknown agent command type: ${type}`); | 					if (host) { | ||||||
|  | 						historyRecord = await prisma.job_history.create({ | ||||||
|  | 							data: { | ||||||
|  | 								id: uuidv4(), | ||||||
|  | 								job_id: job.id, | ||||||
|  | 								queue_name: QUEUE_NAMES.AGENT_COMMANDS, | ||||||
|  | 								job_name: type, | ||||||
|  | 								host_id: host.id, | ||||||
|  | 								api_id: api_id, | ||||||
|  | 								status: "active", | ||||||
|  | 								attempt_number: job.attemptsMade + 1, | ||||||
|  | 								created_at: new Date(), | ||||||
|  | 								updated_at: new Date(), | ||||||
|  | 							}, | ||||||
|  | 						}); | ||||||
|  | 						console.log(`📝 Logged job to job_history: ${job.id} (${type})`); | ||||||
|  | 					} | ||||||
|  | 				} catch (error) { | ||||||
|  | 					console.error("Failed to log job to job_history:", error); | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				try { | ||||||
|  | 					// Send command via WebSocket based on type | ||||||
|  | 					if (type === "report_now") { | ||||||
|  | 						agentWs.pushReportNow(api_id); | ||||||
|  | 					} else if (type === "settings_update") { | ||||||
|  | 						// For settings update, we need additional data | ||||||
|  | 						const { update_interval } = job.data; | ||||||
|  | 						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 { | ||||||
|  | 						console.error(`Unknown agent command type: ${type}`); | ||||||
|  | 					} | ||||||
|  |  | ||||||
|  | 					// Update job history to completed | ||||||
|  | 					if (historyRecord) { | ||||||
|  | 						await prisma.job_history.updateMany({ | ||||||
|  | 							where: { job_id: job.id }, | ||||||
|  | 							data: { | ||||||
|  | 								status: "completed", | ||||||
|  | 								completed_at: new Date(), | ||||||
|  | 								updated_at: new Date(), | ||||||
|  | 							}, | ||||||
|  | 						}); | ||||||
|  | 						console.log(`✅ Marked job as completed in job_history: ${job.id}`); | ||||||
|  | 					} | ||||||
|  | 				} catch (error) { | ||||||
|  | 					// Update job history to failed | ||||||
|  | 					if (historyRecord) { | ||||||
|  | 						await prisma.job_history.updateMany({ | ||||||
|  | 							where: { job_id: job.id }, | ||||||
|  | 							data: { | ||||||
|  | 								status: "failed", | ||||||
|  | 								error_message: error.message, | ||||||
|  | 								completed_at: new Date(), | ||||||
|  | 								updated_at: new Date(), | ||||||
|  | 							}, | ||||||
|  | 						}); | ||||||
|  | 						console.log(`❌ Marked job as failed in job_history: ${job.id}`); | ||||||
|  | 					} | ||||||
|  | 					throw error; | ||||||
| 				} | 				} | ||||||
| 			}, | 			}, | ||||||
| 			workerOptions, | 			workerOptions, | ||||||
| @@ -194,6 +307,7 @@ class QueueManager { | |||||||
| 				console.log(`✅ Job '${job.id}' in queue '${queueName}' completed.`); | 				console.log(`✅ Job '${job.id}' in queue '${queueName}' completed.`); | ||||||
| 			}); | 			}); | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		console.log("✅ Queue events initialized"); | 		console.log("✅ Queue events initialized"); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -205,6 +319,9 @@ 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.DOCKER_IMAGE_UPDATE_CHECK].schedule(); | ||||||
|  | 		await this.automations[QUEUE_NAMES.METRICS_REPORTING].schedule(); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	/** | 	/** | ||||||
| @@ -228,6 +345,22 @@ class QueueManager { | |||||||
| 		].triggerManual(); | 		].triggerManual(); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	async triggerDockerInventoryCleanup() { | ||||||
|  | 		return this.automations[ | ||||||
|  | 			QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP | ||||||
|  | 		].triggerManual(); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	async triggerDockerImageUpdateCheck() { | ||||||
|  | 		return this.automations[ | ||||||
|  | 			QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK | ||||||
|  | 		].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, | ||||||
| 			); | 			); | ||||||
| 		} | 		} | ||||||
|   | |||||||
							
								
								
									
										179
									
								
								backend/src/utils/docker.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								backend/src/utils/docker.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,179 @@ | |||||||
|  | /** | ||||||
|  |  * Docker-related utility functions | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Generate a registry link for a Docker image based on its repository and source | ||||||
|  |  * Inspired by diun's registry link generation | ||||||
|  |  * @param {string} repository - The full repository name (e.g., "ghcr.io/owner/repo") | ||||||
|  |  * @param {string} source - The detected source (github, gitlab, docker-hub, etc.) | ||||||
|  |  * @returns {string|null} - The URL to the registry page, or null if unknown | ||||||
|  |  */ | ||||||
|  | function generateRegistryLink(repository, source) { | ||||||
|  | 	if (!repository) { | ||||||
|  | 		return null; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Parse the domain and path from the repository | ||||||
|  | 	const parts = repository.split("/"); | ||||||
|  | 	let domain = ""; | ||||||
|  | 	let path = ""; | ||||||
|  |  | ||||||
|  | 	// Check if repository has a domain (contains a dot) | ||||||
|  | 	if (parts[0].includes(".") || parts[0].includes(":")) { | ||||||
|  | 		domain = parts[0]; | ||||||
|  | 		path = parts.slice(1).join("/"); | ||||||
|  | 	} else { | ||||||
|  | 		// No domain means Docker Hub | ||||||
|  | 		domain = "docker.io"; | ||||||
|  | 		path = repository; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	switch (source) { | ||||||
|  | 		case "docker-hub": | ||||||
|  | 		case "docker.io": { | ||||||
|  | 			// Docker Hub: https://hub.docker.com/r/{path} or https://hub.docker.com/_/{path} for official images | ||||||
|  | 			// Official images are those without a namespace (e.g., "postgres" not "user/postgres") | ||||||
|  | 			// or explicitly prefixed with "library/" | ||||||
|  | 			if (path.startsWith("library/")) { | ||||||
|  | 				const cleanPath = path.replace("library/", ""); | ||||||
|  | 				return `https://hub.docker.com/_/${cleanPath}`; | ||||||
|  | 			} | ||||||
|  | 			// Check if it's an official image (single part, no slash after removing library/) | ||||||
|  | 			if (!path.includes("/")) { | ||||||
|  | 				return `https://hub.docker.com/_/${path}`; | ||||||
|  | 			} | ||||||
|  | 			// Regular user/org image | ||||||
|  | 			return `https://hub.docker.com/r/${path}`; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		case "github": | ||||||
|  | 		case "ghcr.io": { | ||||||
|  | 			// GitHub Container Registry | ||||||
|  | 			// Format: ghcr.io/{owner}/{package} or ghcr.io/{owner}/{repo}/{package} | ||||||
|  | 			// URL format: https://github.com/{owner}/{repo}/pkgs/container/{package} | ||||||
|  | 			if (domain === "ghcr.io" && path) { | ||||||
|  | 				const pathParts = path.split("/"); | ||||||
|  | 				if (pathParts.length === 2) { | ||||||
|  | 					// Simple case: ghcr.io/owner/package -> github.com/owner/owner/pkgs/container/package | ||||||
|  | 					// OR: ghcr.io/owner/repo -> github.com/owner/repo/pkgs/container/{package} | ||||||
|  | 					// Actually, for 2 parts it's owner/package, and repo is same as owner typically | ||||||
|  | 					const owner = pathParts[0]; | ||||||
|  | 					const packageName = pathParts[1]; | ||||||
|  | 					return `https://github.com/${owner}/${owner}/pkgs/container/${packageName}`; | ||||||
|  | 				} else if (pathParts.length >= 3) { | ||||||
|  | 					// Extended case: ghcr.io/owner/repo/package -> github.com/owner/repo/pkgs/container/package | ||||||
|  | 					const owner = pathParts[0]; | ||||||
|  | 					const repo = pathParts[1]; | ||||||
|  | 					const packageName = pathParts.slice(2).join("/"); | ||||||
|  | 					return `https://github.com/${owner}/${repo}/pkgs/container/${packageName}`; | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 			// Legacy GitHub Packages | ||||||
|  | 			if (domain === "docker.pkg.github.com" && path) { | ||||||
|  | 				const pathParts = path.split("/"); | ||||||
|  | 				if (pathParts.length >= 1) { | ||||||
|  | 					return `https://github.com/${pathParts[0]}/packages`; | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 			return null; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		case "gitlab": | ||||||
|  | 		case "registry.gitlab.com": { | ||||||
|  | 			// GitLab Container Registry: https://gitlab.com/{path}/container_registry | ||||||
|  | 			if (path) { | ||||||
|  | 				return `https://gitlab.com/${path}/container_registry`; | ||||||
|  | 			} | ||||||
|  | 			return null; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		case "google": | ||||||
|  | 		case "gcr.io": { | ||||||
|  | 			// Google Container Registry: https://gcr.io/{path} | ||||||
|  | 			if (domain.includes("gcr.io") || domain.includes("pkg.dev")) { | ||||||
|  | 				return `https://console.cloud.google.com/gcr/images/${path}`; | ||||||
|  | 			} | ||||||
|  | 			return null; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		case "quay": | ||||||
|  | 		case "quay.io": { | ||||||
|  | 			// Quay.io: https://quay.io/repository/{path} | ||||||
|  | 			if (path) { | ||||||
|  | 				return `https://quay.io/repository/${path}`; | ||||||
|  | 			} | ||||||
|  | 			return null; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		case "redhat": | ||||||
|  | 		case "registry.access.redhat.com": { | ||||||
|  | 			// Red Hat: https://access.redhat.com/containers/#/registry.access.redhat.com/{path} | ||||||
|  | 			if (path) { | ||||||
|  | 				return `https://access.redhat.com/containers/#/registry.access.redhat.com/${path}`; | ||||||
|  | 			} | ||||||
|  | 			return null; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		case "azure": | ||||||
|  | 		case "azurecr.io": { | ||||||
|  | 			// Azure Container Registry - link to portal | ||||||
|  | 			// Format: {registry}.azurecr.io/{repository} | ||||||
|  | 			if (domain.includes("azurecr.io")) { | ||||||
|  | 				const registryName = domain.split(".")[0]; | ||||||
|  | 				return `https://portal.azure.com/#view/Microsoft_Azure_ContainerRegistries/RepositoryBlade/registryName/${registryName}/repositoryName/${path}`; | ||||||
|  | 			} | ||||||
|  | 			return null; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		case "aws": | ||||||
|  | 		case "amazonaws.com": { | ||||||
|  | 			// AWS ECR - link to console | ||||||
|  | 			// Format: {account}.dkr.ecr.{region}.amazonaws.com/{repository} | ||||||
|  | 			if (domain.includes("amazonaws.com")) { | ||||||
|  | 				const domainParts = domain.split("."); | ||||||
|  | 				const region = domainParts[3]; // Extract region | ||||||
|  | 				return `https://${region}.console.aws.amazon.com/ecr/repositories/private/${path}`; | ||||||
|  | 			} | ||||||
|  | 			return null; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		case "private": | ||||||
|  | 			// For private registries, try to construct a basic URL | ||||||
|  | 			if (domain) { | ||||||
|  | 				return `https://${domain}`; | ||||||
|  | 			} | ||||||
|  | 			return null; | ||||||
|  |  | ||||||
|  | 		default: | ||||||
|  | 			return null; | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Get a user-friendly display name for a registry source | ||||||
|  |  * @param {string} source - The source identifier | ||||||
|  |  * @returns {string} - Human-readable source name | ||||||
|  |  */ | ||||||
|  | function getSourceDisplayName(source) { | ||||||
|  | 	const sourceNames = { | ||||||
|  | 		"docker-hub": "Docker Hub", | ||||||
|  | 		github: "GitHub", | ||||||
|  | 		gitlab: "GitLab", | ||||||
|  | 		google: "Google", | ||||||
|  | 		quay: "Quay.io", | ||||||
|  | 		redhat: "Red Hat", | ||||||
|  | 		azure: "Azure", | ||||||
|  | 		aws: "AWS ECR", | ||||||
|  | 		private: "Private Registry", | ||||||
|  | 		local: "Local", | ||||||
|  | 		unknown: "Unknown", | ||||||
|  | 	}; | ||||||
|  |  | ||||||
|  | 	return sourceNames[source] || source; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | module.exports = { | ||||||
|  | 	generateRegistryLink, | ||||||
|  | 	getSourceDisplayName, | ||||||
|  | }; | ||||||
| @@ -84,21 +84,20 @@ function parse_expiration(expiration_string) { | |||||||
|  * Generate device fingerprint from request data |  * Generate device fingerprint from request data | ||||||
|  */ |  */ | ||||||
| function generate_device_fingerprint(req) { | function generate_device_fingerprint(req) { | ||||||
| 	const components = [ | 	// Use the X-Device-ID header from frontend (unique per browser profile/localStorage) | ||||||
| 		req.get("user-agent") || "", | 	const deviceId = req.get("x-device-id"); | ||||||
| 		req.get("accept-language") || "", |  | ||||||
| 		req.get("accept-encoding") || "", |  | ||||||
| 		req.ip || "", |  | ||||||
| 	]; |  | ||||||
|  |  | ||||||
| 	// Create a simple hash of device characteristics | 	if (deviceId) { | ||||||
| 	const fingerprint = crypto | 		// Hash the device ID for consistent storage format | ||||||
| 		.createHash("sha256") | 		return crypto | ||||||
| 		.update(components.join("|")) | 			.createHash("sha256") | ||||||
| 		.digest("hex") | 			.update(deviceId) | ||||||
| 		.substring(0, 32); // Use first 32 chars for storage efficiency | 			.digest("hex") | ||||||
|  | 			.substring(0, 32); | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	return fingerprint; | 	// No device ID - return null (user needs to provide device ID for remember-me) | ||||||
|  | 	return null; | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
|   | |||||||
| @@ -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,12 @@ 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 Limiting (times in milliseconds) | ||||||
|       RATE_LIMIT_WINDOW_MS: 900000 |       RATE_LIMIT_WINDOW_MS: 900000 | ||||||
|       RATE_LIMIT_MAX: 5000 |       RATE_LIMIT_MAX: 5000 | ||||||
|   | |||||||
| @@ -56,6 +56,12 @@ 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 Limiting (times in milliseconds) | ||||||
|       RATE_LIMIT_WINDOW_MS: 900000 |       RATE_LIMIT_WINDOW_MS: 900000 | ||||||
|       RATE_LIMIT_MAX: 5000 |       RATE_LIMIT_MAX: 5000 | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| server { | server { | ||||||
|     listen 3000; |     listen 3000; | ||||||
|  | 	listen [::]:3000; | ||||||
|     server_name localhost; |     server_name localhost; | ||||||
|     root /usr/share/nginx/html; |     root /usr/share/nginx/html; | ||||||
|     index index.html; |     index index.html; | ||||||
|   | |||||||
| @@ -6,5 +6,5 @@ VITE_API_URL=http://localhost:3001/api/v1 | |||||||
|  |  | ||||||
| # Application Metadata | # Application Metadata | ||||||
| VITE_APP_NAME=PatchMon | VITE_APP_NAME=PatchMon | ||||||
| VITE_APP_VERSION=1.3.0 | 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.2", | ||||||
| 	"license": "AGPL-3.0", | 	"license": "AGPL-3.0", | ||||||
| 	"type": "module", | 	"type": "module", | ||||||
| 	"scripts": { | 	"scripts": { | ||||||
|   | |||||||
| @@ -7,6 +7,8 @@ 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 { SettingsProvider } from "./contexts/SettingsContext"; | ||||||
| import { ThemeProvider } from "./contexts/ThemeContext"; | import { ThemeProvider } from "./contexts/ThemeContext"; | ||||||
| import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext"; | import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext"; | ||||||
|  |  | ||||||
| @@ -27,6 +29,8 @@ const DockerContainerDetail = lazy( | |||||||
| ); | ); | ||||||
| const DockerImageDetail = lazy(() => import("./pages/docker/ImageDetail")); | const DockerImageDetail = lazy(() => import("./pages/docker/ImageDetail")); | ||||||
| const DockerHostDetail = lazy(() => import("./pages/docker/HostDetail")); | const DockerHostDetail = lazy(() => import("./pages/docker/HostDetail")); | ||||||
|  | const DockerVolumeDetail = lazy(() => import("./pages/docker/VolumeDetail")); | ||||||
|  | const DockerNetworkDetail = lazy(() => import("./pages/docker/NetworkDetail")); | ||||||
| const AlertChannels = lazy(() => import("./pages/settings/AlertChannels")); | const AlertChannels = lazy(() => import("./pages/settings/AlertChannels")); | ||||||
| const Integrations = lazy(() => import("./pages/settings/Integrations")); | const Integrations = lazy(() => import("./pages/settings/Integrations")); | ||||||
| const Notifications = lazy(() => import("./pages/settings/Notifications")); | const Notifications = lazy(() => import("./pages/settings/Notifications")); | ||||||
| @@ -41,6 +45,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 = () => ( | ||||||
| @@ -192,6 +197,26 @@ function AppRoutes() { | |||||||
| 						</ProtectedRoute> | 						</ProtectedRoute> | ||||||
| 					} | 					} | ||||||
| 				/> | 				/> | ||||||
|  | 				<Route | ||||||
|  | 					path="/docker/volumes/:id" | ||||||
|  | 					element={ | ||||||
|  | 						<ProtectedRoute requirePermission="can_view_reports"> | ||||||
|  | 							<Layout> | ||||||
|  | 								<DockerVolumeDetail /> | ||||||
|  | 							</Layout> | ||||||
|  | 						</ProtectedRoute> | ||||||
|  | 					} | ||||||
|  | 				/> | ||||||
|  | 				<Route | ||||||
|  | 					path="/docker/networks/:id" | ||||||
|  | 					element={ | ||||||
|  | 						<ProtectedRoute requirePermission="can_view_reports"> | ||||||
|  | 							<Layout> | ||||||
|  | 								<DockerNetworkDetail /> | ||||||
|  | 							</Layout> | ||||||
|  | 						</ProtectedRoute> | ||||||
|  | 					} | ||||||
|  | 				/> | ||||||
| 				<Route | 				<Route | ||||||
| 					path="/users" | 					path="/users" | ||||||
| 					element={ | 					element={ | ||||||
| @@ -388,6 +413,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={ | ||||||
| @@ -417,11 +452,15 @@ function App() { | |||||||
| 	return ( | 	return ( | ||||||
| 		<ThemeProvider> | 		<ThemeProvider> | ||||||
| 			<AuthProvider> | 			<AuthProvider> | ||||||
| 				<UpdateNotificationProvider> | 				<SettingsProvider> | ||||||
| 					<LogoProvider> | 					<ColorThemeProvider> | ||||||
| 						<AppRoutes /> | 						<UpdateNotificationProvider> | ||||||
| 					</LogoProvider> | 							<LogoProvider> | ||||||
| 				</UpdateNotificationProvider> | 								<AppRoutes /> | ||||||
|  | 							</LogoProvider> | ||||||
|  | 						</UpdateNotificationProvider> | ||||||
|  | 					</ColorThemeProvider> | ||||||
|  | 				</SettingsProvider> | ||||||
| 			</AuthProvider> | 			</AuthProvider> | ||||||
| 		</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,17 +1,8 @@ | |||||||
| import { useQuery } from "@tanstack/react-query"; |  | ||||||
| import { useEffect } from "react"; | import { useEffect } from "react"; | ||||||
| import { isAuthReady } from "../constants/authPhases"; | import { useSettings } from "../contexts/SettingsContext"; | ||||||
| import { useAuth } from "../contexts/AuthContext"; |  | ||||||
| import { settingsAPI } from "../utils/api"; |  | ||||||
|  |  | ||||||
| const LogoProvider = ({ children }) => { | const LogoProvider = ({ children }) => { | ||||||
| 	const { authPhase, isAuthenticated } = useAuth(); | 	const { settings } = useSettings(); | ||||||
|  |  | ||||||
| 	const { data: settings } = useQuery({ |  | ||||||
| 		queryKey: ["settings"], |  | ||||||
| 		queryFn: () => settingsAPI.get().then((res) => res.data), |  | ||||||
| 		enabled: isAuthReady(authPhase, isAuthenticated()), |  | ||||||
| 	}); |  | ||||||
|  |  | ||||||
| 	useEffect(() => { | 	useEffect(() => { | ||||||
| 		// Use custom favicon or fallback to default | 		// Use custom favicon or fallback to default | ||||||
|   | |||||||
| @@ -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, | ||||||
|  | 					}, | ||||||
| 				], | 				], | ||||||
| 			}); | 			}); | ||||||
| 		} | 		} | ||||||
|   | |||||||
| @@ -102,17 +102,28 @@ const BrandingTab = () => { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	return ( | 	return ( | ||||||
| 		<div className="space-y-6"> | 		<div className="space-y-8"> | ||||||
| 			<div className="flex items-center mb-6"> | 			{/* Header */} | ||||||
| 				<Image className="h-6 w-6 text-primary-600 mr-3" /> | 			<div> | ||||||
| 				<h2 className="text-xl font-semibold text-secondary-900 dark:text-white"> | 				<div className="flex items-center mb-6"> | ||||||
| 					Logo & Branding | 					<Image className="h-6 w-6 text-primary-600 mr-3" /> | ||||||
| 				</h2> | 					<h2 className="text-xl font-semibold text-secondary-900 dark:text-white"> | ||||||
|  | 						Logo & Branding | ||||||
|  | 					</h2> | ||||||
|  | 				</div> | ||||||
|  | 				<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6"> | ||||||
|  | 					Customize your PatchMon installation with custom logos and favicon. | ||||||
|  | 					These will be displayed throughout the application. | ||||||
|  | 				</p> | ||||||
|  | 			</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> | ||||||
| 			<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6"> |  | ||||||
| 				Customize your PatchMon installation with custom logos and favicon. |  | ||||||
| 				These will be displayed throughout the application. |  | ||||||
| 			</p> |  | ||||||
|  |  | ||||||
| 			<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"]); | ||||||
|   | |||||||
| @@ -91,10 +91,29 @@ export const AuthProvider = ({ children }) => { | |||||||
|  |  | ||||||
| 	const login = async (username, password) => { | 	const login = async (username, password) => { | ||||||
| 		try { | 		try { | ||||||
|  | 			// Get or generate device ID for TFA remember-me | ||||||
|  | 			let deviceId = localStorage.getItem("device_id"); | ||||||
|  | 			if (!deviceId) { | ||||||
|  | 				if (typeof crypto !== "undefined" && crypto.randomUUID) { | ||||||
|  | 					deviceId = crypto.randomUUID(); | ||||||
|  | 				} else { | ||||||
|  | 					deviceId = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace( | ||||||
|  | 						/[xy]/g, | ||||||
|  | 						(c) => { | ||||||
|  | 							const r = (Math.random() * 16) | 0; | ||||||
|  | 							const v = c === "x" ? r : (r & 0x3) | 0x8; | ||||||
|  | 							return v.toString(16); | ||||||
|  | 						}, | ||||||
|  | 					); | ||||||
|  | 				} | ||||||
|  | 				localStorage.setItem("device_id", deviceId); | ||||||
|  | 			} | ||||||
|  |  | ||||||
| 			const response = await fetch("/api/v1/auth/login", { | 			const response = await fetch("/api/v1/auth/login", { | ||||||
| 				method: "POST", | 				method: "POST", | ||||||
| 				headers: { | 				headers: { | ||||||
| 					"Content-Type": "application/json", | 					"Content-Type": "application/json", | ||||||
|  | 					"X-Device-ID": deviceId, | ||||||
| 				}, | 				}, | ||||||
| 				body: JSON.stringify({ username, password }), | 				body: JSON.stringify({ username, password }), | ||||||
| 			}); | 			}); | ||||||
|   | |||||||
							
								
								
									
										182
									
								
								frontend/src/contexts/ColorThemeContext.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										182
									
								
								frontend/src/contexts/ColorThemeContext.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,182 @@ | |||||||
|  | import { useQuery } from "@tanstack/react-query"; | ||||||
|  | import { createContext, useContext, useEffect, useState } from "react"; | ||||||
|  | import { userPreferencesAPI } from "../utils/api"; | ||||||
|  |  | ||||||
|  | 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(() => { | ||||||
|  | 		// Initialize from localStorage for immediate render | ||||||
|  | 		return localStorage.getItem("colorTheme") || "cyber_blue"; | ||||||
|  | 	}); | ||||||
|  | 	const [isLoading, setIsLoading] = useState(true); | ||||||
|  |  | ||||||
|  | 	// Fetch user preferences from backend | ||||||
|  | 	const { data: userPreferences } = useQuery({ | ||||||
|  | 		queryKey: ["userPreferences"], | ||||||
|  | 		queryFn: () => userPreferencesAPI.get().then((res) => res.data), | ||||||
|  | 		retry: 1, | ||||||
|  | 		staleTime: 5 * 60 * 1000, // 5 minutes | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	// Update theme when preferences are loaded | ||||||
|  | 	useEffect(() => { | ||||||
|  | 		if (userPreferences?.color_theme) { | ||||||
|  | 			setColorTheme(userPreferences.color_theme); | ||||||
|  | 			localStorage.setItem("colorTheme", userPreferences.color_theme); | ||||||
|  | 		} | ||||||
|  | 		setIsLoading(false); | ||||||
|  | 	}, [userPreferences]); | ||||||
|  |  | ||||||
|  | 	const updateColorTheme = async (theme) => { | ||||||
|  | 		setColorTheme(theme); | ||||||
|  | 		localStorage.setItem("colorTheme", theme); | ||||||
|  |  | ||||||
|  | 		// Save to backend | ||||||
|  | 		try { | ||||||
|  | 			await userPreferencesAPI.update({ color_theme: theme }); | ||||||
|  | 		} catch (error) { | ||||||
|  | 			console.error("Failed to save color theme preference:", error); | ||||||
|  | 			// Theme is already set locally, so user still sees the change | ||||||
|  | 		} | ||||||
|  | 	}; | ||||||
|  |  | ||||||
|  | 	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; | ||||||
|  | }; | ||||||
							
								
								
									
										45
									
								
								frontend/src/contexts/SettingsContext.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								frontend/src/contexts/SettingsContext.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | |||||||
|  | import { useQuery } from "@tanstack/react-query"; | ||||||
|  | import { createContext, useContext } from "react"; | ||||||
|  | import { isAuthReady } from "../constants/authPhases"; | ||||||
|  | import { settingsAPI } from "../utils/api"; | ||||||
|  | import { useAuth } from "./AuthContext"; | ||||||
|  |  | ||||||
|  | const SettingsContext = createContext(); | ||||||
|  |  | ||||||
|  | export const useSettings = () => { | ||||||
|  | 	const context = useContext(SettingsContext); | ||||||
|  | 	if (!context) { | ||||||
|  | 		throw new Error("useSettings must be used within a SettingsProvider"); | ||||||
|  | 	} | ||||||
|  | 	return context; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const SettingsProvider = ({ children }) => { | ||||||
|  | 	const { authPhase, isAuthenticated } = useAuth(); | ||||||
|  |  | ||||||
|  | 	const { | ||||||
|  | 		data: settings, | ||||||
|  | 		isLoading, | ||||||
|  | 		error, | ||||||
|  | 		refetch, | ||||||
|  | 	} = useQuery({ | ||||||
|  | 		queryKey: ["settings"], | ||||||
|  | 		queryFn: () => settingsAPI.get().then((res) => res.data), | ||||||
|  | 		staleTime: 5 * 60 * 1000, // Settings stay fresh for 5 minutes | ||||||
|  | 		refetchOnWindowFocus: false, | ||||||
|  | 		enabled: isAuthReady(authPhase, isAuthenticated()), | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	const value = { | ||||||
|  | 		settings, | ||||||
|  | 		isLoading, | ||||||
|  | 		error, | ||||||
|  | 		refetch, | ||||||
|  | 	}; | ||||||
|  |  | ||||||
|  | 	return ( | ||||||
|  | 		<SettingsContext.Provider value={value}> | ||||||
|  | 			{children} | ||||||
|  | 		</SettingsContext.Provider> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
| @@ -1,4 +1,6 @@ | |||||||
|  | import { useQuery } from "@tanstack/react-query"; | ||||||
| import { createContext, useContext, useEffect, useState } from "react"; | import { createContext, useContext, useEffect, useState } from "react"; | ||||||
|  | import { userPreferencesAPI } from "../utils/api"; | ||||||
|  |  | ||||||
| const ThemeContext = createContext(); | const ThemeContext = createContext(); | ||||||
|  |  | ||||||
| @@ -12,7 +14,7 @@ export const useTheme = () => { | |||||||
|  |  | ||||||
| export const ThemeProvider = ({ children }) => { | export const ThemeProvider = ({ children }) => { | ||||||
| 	const [theme, setTheme] = useState(() => { | 	const [theme, setTheme] = useState(() => { | ||||||
| 		// Check localStorage first, then system preference | 		// Check localStorage first for immediate render | ||||||
| 		const savedTheme = localStorage.getItem("theme"); | 		const savedTheme = localStorage.getItem("theme"); | ||||||
| 		if (savedTheme) { | 		if (savedTheme) { | ||||||
| 			return savedTheme; | 			return savedTheme; | ||||||
| @@ -24,6 +26,22 @@ export const ThemeProvider = ({ children }) => { | |||||||
| 		return "light"; | 		return "light"; | ||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
|  | 	// Fetch user preferences from backend | ||||||
|  | 	const { data: userPreferences } = useQuery({ | ||||||
|  | 		queryKey: ["userPreferences"], | ||||||
|  | 		queryFn: () => userPreferencesAPI.get().then((res) => res.data), | ||||||
|  | 		retry: 1, | ||||||
|  | 		staleTime: 5 * 60 * 1000, // 5 minutes | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	// Sync with user preferences from backend | ||||||
|  | 	useEffect(() => { | ||||||
|  | 		if (userPreferences?.theme_preference) { | ||||||
|  | 			setTheme(userPreferences.theme_preference); | ||||||
|  | 			localStorage.setItem("theme", userPreferences.theme_preference); | ||||||
|  | 		} | ||||||
|  | 	}, [userPreferences]); | ||||||
|  |  | ||||||
| 	useEffect(() => { | 	useEffect(() => { | ||||||
| 		// Apply theme to document | 		// Apply theme to document | ||||||
| 		if (theme === "dark") { | 		if (theme === "dark") { | ||||||
| @@ -36,8 +54,17 @@ export const ThemeProvider = ({ children }) => { | |||||||
| 		localStorage.setItem("theme", theme); | 		localStorage.setItem("theme", theme); | ||||||
| 	}, [theme]); | 	}, [theme]); | ||||||
|  |  | ||||||
| 	const toggleTheme = () => { | 	const toggleTheme = async () => { | ||||||
| 		setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light")); | 		const newTheme = theme === "light" ? "dark" : "light"; | ||||||
|  | 		setTheme(newTheme); | ||||||
|  |  | ||||||
|  | 		// Save to backend | ||||||
|  | 		try { | ||||||
|  | 			await userPreferencesAPI.update({ theme_preference: newTheme }); | ||||||
|  | 		} catch (error) { | ||||||
|  | 			console.error("Failed to save theme preference:", error); | ||||||
|  | 			// Theme is already set locally, so user still sees the change | ||||||
|  | 		} | ||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
| 	const value = { | 	const value = { | ||||||
|   | |||||||
| @@ -1,8 +1,5 @@ | |||||||
| import { useQuery } from "@tanstack/react-query"; |  | ||||||
| import { createContext, useContext, useState } from "react"; | import { createContext, useContext, useState } from "react"; | ||||||
| import { isAuthReady } from "../constants/authPhases"; | import { useSettings } from "./SettingsContext"; | ||||||
| import { settingsAPI } from "../utils/api"; |  | ||||||
| import { useAuth } from "./AuthContext"; |  | ||||||
|  |  | ||||||
| const UpdateNotificationContext = createContext(); | const UpdateNotificationContext = createContext(); | ||||||
|  |  | ||||||
| @@ -18,17 +15,7 @@ export const useUpdateNotification = () => { | |||||||
|  |  | ||||||
| export const UpdateNotificationProvider = ({ children }) => { | export const UpdateNotificationProvider = ({ children }) => { | ||||||
| 	const [dismissed, setDismissed] = useState(false); | 	const [dismissed, setDismissed] = useState(false); | ||||||
| 	const { authPhase, isAuthenticated } = useAuth(); | 	const { settings, isLoading: settingsLoading } = useSettings(); | ||||||
|  |  | ||||||
| 	// Ensure settings are loaded - but only after auth is fully ready |  | ||||||
| 	// This reads cached update info from backend (updated by scheduler) |  | ||||||
| 	const { data: settings, isLoading: settingsLoading } = useQuery({ |  | ||||||
| 		queryKey: ["settings"], |  | ||||||
| 		queryFn: () => settingsAPI.get().then((res) => res.data), |  | ||||||
| 		staleTime: 5 * 60 * 1000, // Settings stay fresh for 5 minutes |  | ||||||
| 		refetchOnWindowFocus: false, |  | ||||||
| 		enabled: isAuthReady(authPhase, isAuthenticated()), |  | ||||||
| 	}); |  | ||||||
|  |  | ||||||
| 	// Read cached update information from settings (no GitHub API calls) | 	// Read cached update information from settings (no GitHub API calls) | ||||||
| 	// The backend scheduler updates this data periodically | 	// The backend scheduler updates this data periodically | ||||||
|   | |||||||
| @@ -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") | ||||||
| 															) { | 															) { | ||||||
|   | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -12,6 +12,7 @@ import { | |||||||
| 	Copy, | 	Copy, | ||||||
| 	Cpu, | 	Cpu, | ||||||
| 	Database, | 	Database, | ||||||
|  | 	Download, | ||||||
| 	Eye, | 	Eye, | ||||||
| 	EyeOff, | 	EyeOff, | ||||||
| 	HardDrive, | 	HardDrive, | ||||||
| @@ -53,6 +54,8 @@ const HostDetail = () => { | |||||||
| 	const [historyLimit] = useState(10); | 	const [historyLimit] = useState(10); | ||||||
| 	const [notes, setNotes] = useState(""); | 	const [notes, setNotes] = useState(""); | ||||||
| 	const [notesMessage, setNotesMessage] = useState({ text: "", type: "" }); | 	const [notesMessage, setNotesMessage] = useState({ text: "", type: "" }); | ||||||
|  | 	const [updateMessage, setUpdateMessage] = useState({ text: "", jobId: "" }); | ||||||
|  | 	const [reportMessage, setReportMessage] = useState({ text: "", jobId: "" }); | ||||||
|  |  | ||||||
| 	const { | 	const { | ||||||
| 		data: host, | 		data: host, | ||||||
| @@ -187,6 +190,57 @@ const HostDetail = () => { | |||||||
| 		}, | 		}, | ||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
|  | 	// Force agent update mutation | ||||||
|  | 	const forceAgentUpdateMutation = useMutation({ | ||||||
|  | 		mutationFn: () => | ||||||
|  | 			adminHostsAPI.forceAgentUpdate(hostId).then((res) => res.data), | ||||||
|  | 		onSuccess: (data) => { | ||||||
|  | 			queryClient.invalidateQueries(["host", hostId]); | ||||||
|  | 			queryClient.invalidateQueries(["hosts"]); | ||||||
|  | 			// Show success message with job ID | ||||||
|  | 			if (data?.jobId) { | ||||||
|  | 				setUpdateMessage({ | ||||||
|  | 					text: "Update queued successfully", | ||||||
|  | 					jobId: data.jobId, | ||||||
|  | 				}); | ||||||
|  | 				// Clear message after 5 seconds | ||||||
|  | 				setTimeout(() => setUpdateMessage({ text: "", jobId: "" }), 5000); | ||||||
|  | 			} | ||||||
|  | 		}, | ||||||
|  | 		onError: (error) => { | ||||||
|  | 			setUpdateMessage({ | ||||||
|  | 				text: error.response?.data?.error || "Failed to queue update", | ||||||
|  | 				jobId: "", | ||||||
|  | 			}); | ||||||
|  | 			setTimeout(() => setUpdateMessage({ text: "", jobId: "" }), 5000); | ||||||
|  | 		}, | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	// Fetch report mutation | ||||||
|  | 	const fetchReportMutation = useMutation({ | ||||||
|  | 		mutationFn: () => adminHostsAPI.fetchReport(hostId).then((res) => res.data), | ||||||
|  | 		onSuccess: (data) => { | ||||||
|  | 			queryClient.invalidateQueries(["host", hostId]); | ||||||
|  | 			queryClient.invalidateQueries(["hosts"]); | ||||||
|  | 			// Show success message with job ID | ||||||
|  | 			if (data?.jobId) { | ||||||
|  | 				setReportMessage({ | ||||||
|  | 					text: "Report fetch queued successfully", | ||||||
|  | 					jobId: data.jobId, | ||||||
|  | 				}); | ||||||
|  | 				// Clear message after 5 seconds | ||||||
|  | 				setTimeout(() => setReportMessage({ text: "", jobId: "" }), 5000); | ||||||
|  | 			} | ||||||
|  | 		}, | ||||||
|  | 		onError: (error) => { | ||||||
|  | 			setReportMessage({ | ||||||
|  | 				text: error.response?.data?.error || "Failed to fetch report", | ||||||
|  | 				jobId: "", | ||||||
|  | 			}); | ||||||
|  | 			setTimeout(() => setReportMessage({ text: "", jobId: "" }), 5000); | ||||||
|  | 		}, | ||||||
|  | 	}); | ||||||
|  |  | ||||||
| 	const updateFriendlyNameMutation = useMutation({ | 	const updateFriendlyNameMutation = useMutation({ | ||||||
| 		mutationFn: (friendlyName) => | 		mutationFn: (friendlyName) => | ||||||
| 			adminHostsAPI | 			adminHostsAPI | ||||||
| @@ -399,20 +453,53 @@ const HostDetail = () => { | |||||||
| 					</div> | 					</div> | ||||||
| 				</div> | 				</div> | ||||||
| 				<div className="flex items-center gap-2"> | 				<div className="flex items-center gap-2"> | ||||||
|  | 					<div> | ||||||
|  | 						<button | ||||||
|  | 							type="button" | ||||||
|  | 							onClick={() => fetchReportMutation.mutate()} | ||||||
|  | 							disabled={fetchReportMutation.isPending || !wsStatus?.connected} | ||||||
|  | 							className="btn-outline flex items-center gap-2 text-sm" | ||||||
|  | 							title={ | ||||||
|  | 								!wsStatus?.connected | ||||||
|  | 									? "Agent is not connected" | ||||||
|  | 									: "Fetch package data from agent" | ||||||
|  | 							} | ||||||
|  | 						> | ||||||
|  | 							<Download | ||||||
|  | 								className={`h-4 w-4 ${ | ||||||
|  | 									fetchReportMutation.isPending ? "animate-spin" : "" | ||||||
|  | 								}`} | ||||||
|  | 							/> | ||||||
|  | 							Fetch Report | ||||||
|  | 						</button> | ||||||
|  | 						{reportMessage.text && ( | ||||||
|  | 							<p className="text-xs mt-1.5 text-secondary-600 dark:text-secondary-400"> | ||||||
|  | 								{reportMessage.text} | ||||||
|  | 								{reportMessage.jobId && ( | ||||||
|  | 									<span className="ml-1 font-mono text-secondary-500"> | ||||||
|  | 										(Job #{reportMessage.jobId}) | ||||||
|  | 									</span> | ||||||
|  | 								)} | ||||||
|  | 							</p> | ||||||
|  | 						)} | ||||||
|  | 					</div> | ||||||
| 					<button | 					<button | ||||||
| 						type="button" | 						type="button" | ||||||
| 						onClick={() => setShowCredentialsModal(true)} | 						onClick={() => setShowCredentialsModal(true)} | ||||||
| 						className="btn-outline flex items-center gap-2 text-sm" | 						className={`btn-outline flex items-center text-sm ${ | ||||||
|  | 							host?.machine_id ? "justify-center p-2" : "gap-2" | ||||||
|  | 						}`} | ||||||
|  | 						title="View credentials" | ||||||
| 					> | 					> | ||||||
| 						<Key className="h-4 w-4" /> | 						<Key className="h-4 w-4" /> | ||||||
| 						Deploy Agent | 						{!host?.machine_id && <span>Deploy Agent</span>} | ||||||
| 					</button> | 					</button> | ||||||
| 					<button | 					<button | ||||||
| 						type="button" | 						type="button" | ||||||
| 						onClick={() => refetch()} | 						onClick={() => refetch()} | ||||||
| 						disabled={isFetching} | 						disabled={isFetching} | ||||||
| 						className="btn-outline flex items-center justify-center p-2 text-sm" | 						className="btn-outline flex items-center justify-center p-2 text-sm" | ||||||
| 						title="Refresh host data" | 						title="Refresh dashboard" | ||||||
| 					> | 					> | ||||||
| 						<RefreshCw | 						<RefreshCw | ||||||
| 							className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} | 							className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} | ||||||
| @@ -703,6 +790,49 @@ const HostDetail = () => { | |||||||
| 											/> | 											/> | ||||||
| 										</button> | 										</button> | ||||||
| 									</div> | 									</div> | ||||||
|  |  | ||||||
|  | 									<div> | ||||||
|  | 										<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5"> | ||||||
|  | 											Force Agent Version Upgrade | ||||||
|  | 										</p> | ||||||
|  | 										<button | ||||||
|  | 											type="button" | ||||||
|  | 											onClick={() => forceAgentUpdateMutation.mutate()} | ||||||
|  | 											disabled={ | ||||||
|  | 												forceAgentUpdateMutation.isPending || | ||||||
|  | 												!wsStatus?.connected | ||||||
|  | 											} | ||||||
|  | 											title={ | ||||||
|  | 												!wsStatus?.connected | ||||||
|  | 													? "Agent is not connected" | ||||||
|  | 													: "Force agent to update now" | ||||||
|  | 											} | ||||||
|  | 											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..." | ||||||
|  | 												: wsStatus?.connected | ||||||
|  | 													? "Update Now" | ||||||
|  | 													: "Offline"} | ||||||
|  | 										</button> | ||||||
|  | 										{updateMessage.text && ( | ||||||
|  | 											<p className="text-xs mt-1.5 text-secondary-600 dark:text-secondary-400"> | ||||||
|  | 												{updateMessage.text} | ||||||
|  | 												{updateMessage.jobId && ( | ||||||
|  | 													<span className="ml-1 font-mono text-secondary-500"> | ||||||
|  | 														(Job #{updateMessage.jobId}) | ||||||
|  | 													</span> | ||||||
|  | 												)} | ||||||
|  | 											</p> | ||||||
|  | 										)} | ||||||
|  | 									</div> | ||||||
| 								</div> | 								</div> | ||||||
| 							</div> | 							</div> | ||||||
| 						)} | 						)} | ||||||
|   | |||||||
| @@ -470,9 +470,18 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => { | |||||||
|  |  | ||||||
| // Delete Confirmation Modal | // Delete Confirmation Modal | ||||||
| const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => { | const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => { | ||||||
|  | 	// Fetch hosts for this group | ||||||
|  | 	const { data: hostsData } = useQuery({ | ||||||
|  | 		queryKey: ["hostGroupHosts", group?.id], | ||||||
|  | 		queryFn: () => hostGroupsAPI.getHosts(group.id).then((res) => res.data), | ||||||
|  | 		enabled: !!group && group._count?.hosts > 0, | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	const hosts = hostsData || []; | ||||||
|  |  | ||||||
| 	return ( | 	return ( | ||||||
| 		<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> | 		<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 w-full max-w-md"> | 			<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto"> | ||||||
| 				<div className="flex items-center gap-3 mb-4"> | 				<div className="flex items-center gap-3 mb-4"> | ||||||
| 					<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center"> | 					<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center"> | ||||||
| 						<AlertTriangle className="h-5 w-5 text-danger-600" /> | 						<AlertTriangle className="h-5 w-5 text-danger-600" /> | ||||||
| @@ -494,12 +503,30 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => { | |||||||
| 					</p> | 					</p> | ||||||
| 					{group._count.hosts > 0 && ( | 					{group._count.hosts > 0 && ( | ||||||
| 						<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md"> | 						<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md"> | ||||||
| 							<p className="text-sm text-warning-800"> | 							<p className="text-sm text-warning-800 mb-2"> | ||||||
| 								<strong>Warning:</strong> This group contains{" "} | 								<strong>Warning:</strong> This group contains{" "} | ||||||
| 								{group._count.hosts} host | 								{group._count.hosts} host | ||||||
| 								{group._count.hosts !== 1 ? "s" : ""}. You must move or remove | 								{group._count.hosts !== 1 ? "s" : ""}. You must move or remove | ||||||
| 								these hosts before deleting the group. | 								these hosts before deleting the group. | ||||||
| 							</p> | 							</p> | ||||||
|  | 							{hosts.length > 0 && ( | ||||||
|  | 								<div className="mt-2"> | ||||||
|  | 									<p className="text-xs font-medium text-warning-900 mb-1"> | ||||||
|  | 										Hosts in this group: | ||||||
|  | 									</p> | ||||||
|  | 									<div className="max-h-32 overflow-y-auto bg-warning-100 rounded p-2"> | ||||||
|  | 										{hosts.map((host) => ( | ||||||
|  | 											<div | ||||||
|  | 												key={host.id} | ||||||
|  | 												className="text-xs text-warning-900 flex items-center gap-1" | ||||||
|  | 											> | ||||||
|  | 												<Server className="h-3 w-3" /> | ||||||
|  | 												{host.friendly_name || host.hostname} | ||||||
|  | 											</div> | ||||||
|  | 										))} | ||||||
|  | 									</div> | ||||||
|  | 								</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); | ||||||
| 					try { |  | ||||||
| 						const response = await fetch(`/api/v1/ws/status/${host.api_id}`, { |  | ||||||
| 							headers: { |  | ||||||
| 								Authorization: `Bearer ${token}`, |  | ||||||
| 							}, |  | ||||||
| 						}); |  | ||||||
| 						if (response.ok) { |  | ||||||
| 							const data = await response.json(); |  | ||||||
| 							return { apiId: host.api_id, status: data.data }; |  | ||||||
| 						} |  | ||||||
| 					} catch (_error) { |  | ||||||
| 						// Silently handle errors |  | ||||||
| 					} |  | ||||||
| 					return { |  | ||||||
| 						apiId: host.api_id, |  | ||||||
| 						status: { connected: false, secure: false }, |  | ||||||
| 					}; |  | ||||||
| 				}); |  | ||||||
|  |  | ||||||
| 			const results = await Promise.all(statusPromises); | 			if (apiIds.length === 0) return; | ||||||
| 			const initialStatusMap = {}; |  | ||||||
| 			results.forEach(({ apiId, status }) => { |  | ||||||
| 				initialStatusMap[apiId] = status; |  | ||||||
| 			}); |  | ||||||
|  |  | ||||||
| 			setWsStatusMap(initialStatusMap); | 			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 | ||||||
|  | 			} | ||||||
| 		}; | 		}; | ||||||
|  |  | ||||||
| 		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 (_err) { |  | ||||||
| 						// Silently handle parse errors |  | ||||||
| 					} | 					} | ||||||
| 				}; | 				}) | ||||||
|  | 				.catch(() => { | ||||||
| 				es.onerror = (_error) => { | 					// Silently handle errors | ||||||
| 					console.log(`[SSE] Connection error for ${apiId}, retrying...`); | 				}); | ||||||
| 					es?.close(); | 		}, 10000); // Poll every 10 seconds | ||||||
| 					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]); | ||||||
|  |  | ||||||
| @@ -565,12 +531,11 @@ const Hosts = () => { | |||||||
| 							"with new data:", | 							"with new data:", | ||||||
| 							data.host, | 							data.host, | ||||||
| 						); | 						); | ||||||
| 						// Ensure hostGroupId is set correctly | 						// Host already has host_group_memberships from backend | ||||||
| 						const updatedHost = { | 						const updatedHost = { | ||||||
| 							...data.host, | 							...data.host, | ||||||
| 							hostGroupId: data.host.host_groups?.id || null, |  | ||||||
| 						}; | 						}; | ||||||
| 						console.log("Updated host with hostGroupId:", updatedHost); | 						console.log("Updated host in cache:", updatedHost); | ||||||
| 						return updatedHost; | 						return updatedHost; | ||||||
| 					} | 					} | ||||||
| 					return host; | 					return host; | ||||||
| @@ -688,11 +653,15 @@ const Hosts = () => { | |||||||
| 				host.os_type?.toLowerCase().includes(searchTerm.toLowerCase()) || | 				host.os_type?.toLowerCase().includes(searchTerm.toLowerCase()) || | ||||||
| 				host.notes?.toLowerCase().includes(searchTerm.toLowerCase()); | 				host.notes?.toLowerCase().includes(searchTerm.toLowerCase()); | ||||||
|  |  | ||||||
| 			// Group filter | 			// Group filter - handle multiple groups per host | ||||||
|  | 			const memberships = host.host_group_memberships || []; | ||||||
| 			const matchesGroup = | 			const matchesGroup = | ||||||
| 				groupFilter === "all" || | 				groupFilter === "all" || | ||||||
| 				(groupFilter === "ungrouped" && !host.host_groups) || | 				(groupFilter === "ungrouped" && memberships.length === 0) || | ||||||
| 				(groupFilter !== "ungrouped" && host.host_groups?.id === groupFilter); | 				(groupFilter !== "ungrouped" && | ||||||
|  | 					memberships.some( | ||||||
|  | 						(membership) => membership.host_groups?.id === groupFilter, | ||||||
|  | 					)); | ||||||
|  |  | ||||||
| 			// Status filter | 			// Status filter | ||||||
| 			const matchesStatus = | 			const matchesStatus = | ||||||
| @@ -745,10 +714,30 @@ const Hosts = () => { | |||||||
| 					aValue = a.ip?.toLowerCase() || "zzz_no_ip"; | 					aValue = a.ip?.toLowerCase() || "zzz_no_ip"; | ||||||
| 					bValue = b.ip?.toLowerCase() || "zzz_no_ip"; | 					bValue = b.ip?.toLowerCase() || "zzz_no_ip"; | ||||||
| 					break; | 					break; | ||||||
| 				case "group": | 				case "group": { | ||||||
| 					aValue = a.host_groups?.name || "zzz_ungrouped"; | 					// Handle multiple groups per host - use first group alphabetically for sorting | ||||||
| 					bValue = b.host_groups?.name || "zzz_ungrouped"; | 					const aGroups = a.host_group_memberships || []; | ||||||
|  | 					const bGroups = b.host_group_memberships || []; | ||||||
|  | 					if (aGroups.length === 0) { | ||||||
|  | 						aValue = "zzz_ungrouped"; | ||||||
|  | 					} else { | ||||||
|  | 						const aGroupNames = aGroups | ||||||
|  | 							.map((m) => m.host_groups?.name || "") | ||||||
|  | 							.filter((name) => name) | ||||||
|  | 							.sort(); | ||||||
|  | 						aValue = aGroupNames[0] || "zzz_ungrouped"; | ||||||
|  | 					} | ||||||
|  | 					if (bGroups.length === 0) { | ||||||
|  | 						bValue = "zzz_ungrouped"; | ||||||
|  | 					} else { | ||||||
|  | 						const bGroupNames = bGroups | ||||||
|  | 							.map((m) => m.host_groups?.name || "") | ||||||
|  | 							.filter((name) => name) | ||||||
|  | 							.sort(); | ||||||
|  | 						bValue = bGroupNames[0] || "zzz_ungrouped"; | ||||||
|  | 					} | ||||||
| 					break; | 					break; | ||||||
|  | 				} | ||||||
| 				case "os": | 				case "os": | ||||||
| 					aValue = a.os_type?.toLowerCase() || "zzz_unknown"; | 					aValue = a.os_type?.toLowerCase() || "zzz_unknown"; | ||||||
| 					bValue = b.os_type?.toLowerCase() || "zzz_unknown"; | 					bValue = b.os_type?.toLowerCase() || "zzz_unknown"; | ||||||
| @@ -821,27 +810,46 @@ const Hosts = () => { | |||||||
|  |  | ||||||
| 		const groups = {}; | 		const groups = {}; | ||||||
| 		filteredAndSortedHosts.forEach((host) => { | 		filteredAndSortedHosts.forEach((host) => { | ||||||
| 			let groupKey; | 			if (groupBy === "group") { | ||||||
| 			switch (groupBy) { | 				// Handle multiple groups per host | ||||||
| 				case "group": | 				const memberships = host.host_group_memberships || []; | ||||||
| 					groupKey = host.host_groups?.name || "Ungrouped"; | 				if (memberships.length === 0) { | ||||||
| 					break; | 					// Host has no groups, add to "Ungrouped" | ||||||
| 				case "status": | 					if (!groups.Ungrouped) { | ||||||
| 					groupKey = | 						groups.Ungrouped = []; | ||||||
| 						(host.effectiveStatus || host.status).charAt(0).toUpperCase() + | 					} | ||||||
| 						(host.effectiveStatus || host.status).slice(1); | 					groups.Ungrouped.push(host); | ||||||
| 					break; | 				} else { | ||||||
| 				case "os": | 					// Host has one or more groups, add to each group | ||||||
| 					groupKey = host.os_type || "Unknown"; | 					memberships.forEach((membership) => { | ||||||
| 					break; | 						const groupName = membership.host_groups?.name || "Unknown"; | ||||||
| 				default: | 						if (!groups[groupName]) { | ||||||
| 					groupKey = "All Hosts"; | 							groups[groupName] = []; | ||||||
| 			} | 						} | ||||||
|  | 						groups[groupName].push(host); | ||||||
|  | 					}); | ||||||
|  | 				} | ||||||
|  | 			} else { | ||||||
|  | 				// Other grouping types (status, os, etc.) | ||||||
|  | 				let groupKey; | ||||||
|  | 				switch (groupBy) { | ||||||
|  | 					case "status": | ||||||
|  | 						groupKey = | ||||||
|  | 							(host.effectiveStatus || host.status).charAt(0).toUpperCase() + | ||||||
|  | 							(host.effectiveStatus || host.status).slice(1); | ||||||
|  | 						break; | ||||||
|  | 					case "os": | ||||||
|  | 						groupKey = host.os_type || "Unknown"; | ||||||
|  | 						break; | ||||||
|  | 					default: | ||||||
|  | 						groupKey = "All Hosts"; | ||||||
|  | 				} | ||||||
|  |  | ||||||
| 			if (!groups[groupKey]) { | 				if (!groups[groupKey]) { | ||||||
| 				groups[groupKey] = []; | 					groups[groupKey] = []; | ||||||
|  | 				} | ||||||
|  | 				groups[groupKey].push(host); | ||||||
| 			} | 			} | ||||||
| 			groups[groupKey].push(host); |  | ||||||
| 		}); | 		}); | ||||||
|  |  | ||||||
| 		return groups; | 		return groups; | ||||||
| @@ -1428,14 +1436,6 @@ const Hosts = () => { | |||||||
| 									<AlertTriangle className="h-4 w-4" /> | 									<AlertTriangle className="h-4 w-4" /> | ||||||
| 									Hide Stale | 									Hide Stale | ||||||
| 								</button> | 								</button> | ||||||
| 								<button |  | ||||||
| 									type="button" |  | ||||||
| 									onClick={() => setShowAddModal(true)} |  | ||||||
| 									className="btn-primary flex items-center gap-2" |  | ||||||
| 								> |  | ||||||
| 									<Plus className="h-4 w-4" /> |  | ||||||
| 									Add Host |  | ||||||
| 								</button> |  | ||||||
| 							</div> | 							</div> | ||||||
| 						</div> | 						</div> | ||||||
|  |  | ||||||
|   | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -557,9 +557,18 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => { | |||||||
|  |  | ||||||
| // Delete Confirmation Modal | // Delete Confirmation Modal | ||||||
| const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => { | const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => { | ||||||
|  | 	// Fetch hosts for this group | ||||||
|  | 	const { data: hostsData } = useQuery({ | ||||||
|  | 		queryKey: ["hostGroupHosts", group?.id], | ||||||
|  | 		queryFn: () => hostGroupsAPI.getHosts(group.id).then((res) => res.data), | ||||||
|  | 		enabled: !!group && group._count?.hosts > 0, | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	const hosts = hostsData || []; | ||||||
|  |  | ||||||
| 	return ( | 	return ( | ||||||
| 		<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> | 		<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 w-full max-w-md"> | 			<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto"> | ||||||
| 				<div className="flex items-center gap-3 mb-4"> | 				<div className="flex items-center gap-3 mb-4"> | ||||||
| 					<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center"> | 					<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center"> | ||||||
| 						<AlertTriangle className="h-5 w-5 text-danger-600" /> | 						<AlertTriangle className="h-5 w-5 text-danger-600" /> | ||||||
| @@ -581,12 +590,30 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => { | |||||||
| 					</p> | 					</p> | ||||||
| 					{group._count.hosts > 0 && ( | 					{group._count.hosts > 0 && ( | ||||||
| 						<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md"> | 						<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md"> | ||||||
| 							<p className="text-sm text-warning-800"> | 							<p className="text-sm text-warning-800 mb-2"> | ||||||
| 								<strong>Warning:</strong> This group contains{" "} | 								<strong>Warning:</strong> This group contains{" "} | ||||||
| 								{group._count.hosts} host | 								{group._count.hosts} host | ||||||
| 								{group._count.hosts !== 1 ? "s" : ""}. You must move or remove | 								{group._count.hosts !== 1 ? "s" : ""}. You must move or remove | ||||||
| 								these hosts before deleting the group. | 								these hosts before deleting the group. | ||||||
| 							</p> | 							</p> | ||||||
|  | 							{hosts.length > 0 && ( | ||||||
|  | 								<div className="mt-2"> | ||||||
|  | 									<p className="text-xs font-medium text-warning-900 mb-1"> | ||||||
|  | 										Hosts in this group: | ||||||
|  | 									</p> | ||||||
|  | 									<div className="max-h-32 overflow-y-auto bg-warning-100 rounded p-2"> | ||||||
|  | 										{hosts.map((host) => ( | ||||||
|  | 											<div | ||||||
|  | 												key={host.id} | ||||||
|  | 												className="text-xs text-warning-900 flex items-center gap-1" | ||||||
|  | 											> | ||||||
|  | 												<Server className="h-3 w-3" /> | ||||||
|  | 												{host.friendly_name || host.hostname} | ||||||
|  | 											</div> | ||||||
|  | 										))} | ||||||
|  | 									</div> | ||||||
|  | 								</div> | ||||||
|  | 							)} | ||||||
| 						</div> | 						</div> | ||||||
| 					)} | 					)} | ||||||
| 				</div> | 				</div> | ||||||
|   | |||||||
| @@ -539,7 +539,7 @@ const Packages = () => { | |||||||
| 						<Package className="h-5 w-5 text-primary-600 mr-2" /> | 						<Package className="h-5 w-5 text-primary-600 mr-2" /> | ||||||
| 						<div> | 						<div> | ||||||
| 							<p className="text-sm text-secondary-500 dark:text-white"> | 							<p className="text-sm text-secondary-500 dark:text-white"> | ||||||
| 								Total Packages | 								Packages | ||||||
| 							</p> | 							</p> | ||||||
| 							<p className="text-xl font-semibold text-secondary-900 dark:text-white"> | 							<p className="text-xl font-semibold text-secondary-900 dark:text-white"> | ||||||
| 								{totalPackagesCount} | 								{totalPackagesCount} | ||||||
| @@ -553,7 +553,7 @@ const Packages = () => { | |||||||
| 						<Package className="h-5 w-5 text-blue-600 mr-2" /> | 						<Package className="h-5 w-5 text-blue-600 mr-2" /> | ||||||
| 						<div> | 						<div> | ||||||
| 							<p className="text-sm text-secondary-500 dark:text-white"> | 							<p className="text-sm text-secondary-500 dark:text-white"> | ||||||
| 								Total Installations | 								Installations | ||||||
| 							</p> | 							</p> | ||||||
| 							<p className="text-xl font-semibold text-secondary-900 dark:text-white"> | 							<p className="text-xl font-semibold text-secondary-900 dark:text-white"> | ||||||
| 								{totalInstallationsCount} | 								{totalInstallationsCount} | ||||||
| @@ -562,47 +562,72 @@ const Packages = () => { | |||||||
| 					</div> | 					</div> | ||||||
| 				</div> | 				</div> | ||||||
|  |  | ||||||
| 				<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"> | 				<button | ||||||
|  | 					type="button" | ||||||
|  | 					onClick={() => { | ||||||
|  | 						setUpdateStatusFilter("needs-updates"); | ||||||
|  | 						setCategoryFilter("all"); | ||||||
|  | 						setHostFilter("all"); | ||||||
|  | 						setSearchTerm(""); | ||||||
|  | 					}} | ||||||
|  | 					className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full" | ||||||
|  | 					title="Click to filter packages that need updates" | ||||||
|  | 				> | ||||||
| 					<div className="flex items-center"> | 					<div className="flex items-center"> | ||||||
| 						<Package className="h-5 w-5 text-warning-600 mr-2" /> | 						<Package className="h-5 w-5 text-warning-600 mr-2" /> | ||||||
| 						<div> | 						<div> | ||||||
| 							<p className="text-sm text-secondary-500 dark:text-white"> | 							<p className="text-sm text-secondary-500 dark:text-white"> | ||||||
| 								Total Outdated Packages | 								Outdated Packages | ||||||
| 							</p> | 							</p> | ||||||
| 							<p className="text-xl font-semibold text-secondary-900 dark:text-white"> | 							<p className="text-xl font-semibold text-secondary-900 dark:text-white"> | ||||||
| 								{outdatedPackagesCount} | 								{outdatedPackagesCount} | ||||||
| 							</p> | 							</p> | ||||||
| 						</div> | 						</div> | ||||||
| 					</div> | 					</div> | ||||||
| 				</div> | 				</button> | ||||||
|  |  | ||||||
| 				<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"> | 				<button | ||||||
| 					<div className="flex items-center"> | 					type="button" | ||||||
| 						<Server className="h-5 w-5 text-warning-600 mr-2" /> | 					onClick={() => { | ||||||
| 						<div> | 						setUpdateStatusFilter("security-updates"); | ||||||
| 							<p className="text-sm text-secondary-500 dark:text-white"> | 						setCategoryFilter("all"); | ||||||
| 								Hosts Pending Updates | 						setHostFilter("all"); | ||||||
| 							</p> | 						setSearchTerm(""); | ||||||
| 							<p className="text-xl font-semibold text-secondary-900 dark:text-white"> | 					}} | ||||||
| 								{uniquePackageHostsCount} | 					className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full" | ||||||
| 							</p> | 					title="Click to filter packages with security updates" | ||||||
| 						</div> | 				> | ||||||
| 					</div> |  | ||||||
| 				</div> |  | ||||||
|  |  | ||||||
| 				<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"> |  | ||||||
| 					<div className="flex items-center"> | 					<div className="flex items-center"> | ||||||
| 						<Shield className="h-5 w-5 text-danger-600 mr-2" /> | 						<Shield className="h-5 w-5 text-danger-600 mr-2" /> | ||||||
| 						<div> | 						<div> | ||||||
| 							<p className="text-sm text-secondary-500 dark:text-white"> | 							<p className="text-sm text-secondary-500 dark:text-white"> | ||||||
| 								Security Updates Across All Hosts | 								Security Packages | ||||||
| 							</p> | 							</p> | ||||||
| 							<p className="text-xl font-semibold text-secondary-900 dark:text-white"> | 							<p className="text-xl font-semibold text-secondary-900 dark:text-white"> | ||||||
| 								{securityUpdatesCount} | 								{securityUpdatesCount} | ||||||
| 							</p> | 							</p> | ||||||
| 						</div> | 						</div> | ||||||
| 					</div> | 					</div> | ||||||
| 				</div> | 				</button> | ||||||
|  |  | ||||||
|  | 				<button | ||||||
|  | 					type="button" | ||||||
|  | 					onClick={() => navigate("/hosts?filter=needsUpdates")} | ||||||
|  | 					className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full" | ||||||
|  | 					title="Click to view hosts that need updates" | ||||||
|  | 				> | ||||||
|  | 					<div className="flex items-center"> | ||||||
|  | 						<Server className="h-5 w-5 text-warning-600 mr-2" /> | ||||||
|  | 						<div> | ||||||
|  | 							<p className="text-sm text-secondary-500 dark:text-white"> | ||||||
|  | 								Outdated Hosts | ||||||
|  | 							</p> | ||||||
|  | 							<p className="text-xl font-semibold text-secondary-900 dark:text-white"> | ||||||
|  | 								{uniquePackageHostsCount} | ||||||
|  | 							</p> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</button> | ||||||
| 			</div> | 			</div> | ||||||
|  |  | ||||||
| 			{/* Packages List */} | 			{/* Packages List */} | ||||||
|   | |||||||
| @@ -25,6 +25,7 @@ import { | |||||||
| import { useEffect, useId, useState } from "react"; | import { useEffect, useId, useState } from "react"; | ||||||
|  |  | ||||||
| import { useAuth } from "../contexts/AuthContext"; | import { useAuth } from "../contexts/AuthContext"; | ||||||
|  | import { THEME_PRESETS, useColorTheme } from "../contexts/ColorThemeContext"; | ||||||
| import { useTheme } from "../contexts/ThemeContext"; | import { useTheme } from "../contexts/ThemeContext"; | ||||||
| import { isCorsError, tfaAPI } from "../utils/api"; | import { isCorsError, tfaAPI } from "../utils/api"; | ||||||
|  |  | ||||||
| @@ -38,6 +39,7 @@ const Profile = () => { | |||||||
| 	const confirmPasswordId = useId(); | 	const confirmPasswordId = useId(); | ||||||
| 	const { user, updateProfile, changePassword } = useAuth(); | 	const { user, updateProfile, changePassword } = useAuth(); | ||||||
| 	const { toggleTheme, isDark } = useTheme(); | 	const { toggleTheme, isDark } = useTheme(); | ||||||
|  | 	const { colorTheme, setColorTheme } = useColorTheme(); | ||||||
| 	const [activeTab, setActiveTab] = useState("profile"); | 	const [activeTab, setActiveTab] = useState("profile"); | ||||||
| 	const [isLoading, setIsLoading] = useState(false); | 	const [isLoading, setIsLoading] = useState(false); | ||||||
| 	const [message, setMessage] = useState({ type: "", text: "" }); | 	const [message, setMessage] = useState({ type: "", text: "" }); | ||||||
| @@ -411,6 +413,68 @@ const Profile = () => { | |||||||
| 										</button> | 										</button> | ||||||
| 									</div> | 									</div> | ||||||
| 								</div> | 								</div> | ||||||
|  |  | ||||||
|  | 								{/* Color Theme Settings */} | ||||||
|  | 								<div className="mt-6 pt-6 border-t border-secondary-200 dark:border-secondary-600"> | ||||||
|  | 									<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-2"> | ||||||
|  | 										Color Theme | ||||||
|  | 									</h4> | ||||||
|  | 									<p className="text-xs text-secondary-500 dark:text-secondary-400 mb-4"> | ||||||
|  | 										Choose your preferred color scheme for the application | ||||||
|  | 									</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={() => setColorTheme(themeKey)} | ||||||
|  | 													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" | ||||||
|  | 													} 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> | ||||||
|  | 								</div> | ||||||
| 							</div> | 							</div> | ||||||
|  |  | ||||||
| 							<div className="flex justify-end"> | 							<div className="flex justify-end"> | ||||||
| @@ -564,6 +628,7 @@ const Profile = () => { | |||||||
| // TFA Tab Component | // TFA Tab Component | ||||||
| const TfaTab = () => { | const TfaTab = () => { | ||||||
| 	const verificationTokenId = useId(); | 	const verificationTokenId = useId(); | ||||||
|  | 	const disablePasswordId = useId(); | ||||||
| 	const [setupStep, setSetupStep] = useState("status"); // 'status', 'setup', 'verify', 'backup-codes' | 	const [setupStep, setSetupStep] = useState("status"); // 'status', 'setup', 'verify', 'backup-codes' | ||||||
| 	const [verificationToken, setVerificationToken] = useState(""); | 	const [verificationToken, setVerificationToken] = useState(""); | ||||||
| 	const [password, setPassword] = useState(""); | 	const [password, setPassword] = useState(""); | ||||||
|   | |||||||
							
								
								
									
										483
									
								
								frontend/src/pages/docker/NetworkDetail.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										483
									
								
								frontend/src/pages/docker/NetworkDetail.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,483 @@ | |||||||
|  | import { useQuery } from "@tanstack/react-query"; | ||||||
|  | import { | ||||||
|  | 	AlertTriangle, | ||||||
|  | 	ArrowLeft, | ||||||
|  | 	CheckCircle, | ||||||
|  | 	Container, | ||||||
|  | 	Globe, | ||||||
|  | 	Network, | ||||||
|  | 	RefreshCw, | ||||||
|  | 	Server, | ||||||
|  | 	Tag, | ||||||
|  | 	XCircle, | ||||||
|  | } from "lucide-react"; | ||||||
|  | import { Link, useParams } from "react-router-dom"; | ||||||
|  | import api, { formatRelativeTime } from "../../utils/api"; | ||||||
|  |  | ||||||
|  | const NetworkDetail = () => { | ||||||
|  | 	const { id } = useParams(); | ||||||
|  |  | ||||||
|  | 	const { data, isLoading, error } = useQuery({ | ||||||
|  | 		queryKey: ["docker", "network", id], | ||||||
|  | 		queryFn: async () => { | ||||||
|  | 			const response = await api.get(`/docker/networks/${id}`); | ||||||
|  | 			return response.data; | ||||||
|  | 		}, | ||||||
|  | 		refetchInterval: 30000, | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	const network = data?.network; | ||||||
|  | 	const host = data?.host; | ||||||
|  |  | ||||||
|  | 	if (isLoading) { | ||||||
|  | 		return ( | ||||||
|  | 			<div className="flex items-center justify-center min-h-screen"> | ||||||
|  | 				<RefreshCw className="h-8 w-8 animate-spin text-secondary-400" /> | ||||||
|  | 			</div> | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if (error || !network) { | ||||||
|  | 		return ( | ||||||
|  | 			<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> | ||||||
|  | 				<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"> | ||||||
|  | 					<div className="flex"> | ||||||
|  | 						<AlertTriangle className="h-5 w-5 text-red-400" /> | ||||||
|  | 						<div className="ml-3"> | ||||||
|  | 							<h3 className="text-sm font-medium text-red-800 dark:text-red-200"> | ||||||
|  | 								Network not found | ||||||
|  | 							</h3> | ||||||
|  | 							<p className="mt-2 text-sm text-red-700 dark:text-red-300"> | ||||||
|  | 								The network you're looking for doesn't exist or has been | ||||||
|  | 								removed. | ||||||
|  | 							</p> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  | 				<Link | ||||||
|  | 					to="/docker" | ||||||
|  | 					className="mt-4 inline-flex items-center text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300" | ||||||
|  | 				> | ||||||
|  | 					<ArrowLeft className="h-4 w-4 mr-2" /> | ||||||
|  | 					Back to Docker | ||||||
|  | 				</Link> | ||||||
|  | 			</div> | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	const BooleanBadge = ({ value, trueLabel = "Yes", falseLabel = "No" }) => { | ||||||
|  | 		return value ? ( | ||||||
|  | 			<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"> | ||||||
|  | 				<CheckCircle className="h-3 w-3 mr-1" /> | ||||||
|  | 				{trueLabel} | ||||||
|  | 			</span> | ||||||
|  | 		) : ( | ||||||
|  | 			<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"> | ||||||
|  | 				<XCircle className="h-3 w-3 mr-1" /> | ||||||
|  | 				{falseLabel} | ||||||
|  | 			</span> | ||||||
|  | 		); | ||||||
|  | 	}; | ||||||
|  |  | ||||||
|  | 	return ( | ||||||
|  | 		<div className="space-y-6"> | ||||||
|  | 			{/* Header */} | ||||||
|  | 			<div> | ||||||
|  | 				<Link | ||||||
|  | 					to="/docker" | ||||||
|  | 					className="inline-flex items-center text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 mb-4" | ||||||
|  | 				> | ||||||
|  | 					<ArrowLeft className="h-4 w-4 mr-2" /> | ||||||
|  | 					Back to Docker | ||||||
|  | 				</Link> | ||||||
|  | 				<div className="flex items-center"> | ||||||
|  | 					<Network className="h-8 w-8 text-secondary-400 mr-3" /> | ||||||
|  | 					<div> | ||||||
|  | 						<h1 className="text-2xl font-bold text-secondary-900 dark:text-white"> | ||||||
|  | 							{network.name} | ||||||
|  | 						</h1> | ||||||
|  | 						<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400"> | ||||||
|  | 							Network ID: {network.network_id.substring(0, 12)} | ||||||
|  | 						</p> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  | 			</div> | ||||||
|  |  | ||||||
|  | 			{/* Overview Cards */} | ||||||
|  | 			<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4"> | ||||||
|  | 				<div className="card p-4"> | ||||||
|  | 					<div className="flex items-center"> | ||||||
|  | 						<div className="flex-shrink-0"> | ||||||
|  | 							<Network className="h-5 w-5 text-blue-600 mr-2" /> | ||||||
|  | 						</div> | ||||||
|  | 						<div className="w-0 flex-1"> | ||||||
|  | 							<p className="text-sm text-secondary-500 dark:text-white"> | ||||||
|  | 								Driver | ||||||
|  | 							</p> | ||||||
|  | 							<p className="text-xl font-semibold text-secondary-900 dark:text-white"> | ||||||
|  | 								{network.driver} | ||||||
|  | 							</p> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  |  | ||||||
|  | 				<div className="card p-4"> | ||||||
|  | 					<div className="flex items-center"> | ||||||
|  | 						<div className="flex-shrink-0"> | ||||||
|  | 							<Globe className="h-5 w-5 text-purple-600 mr-2" /> | ||||||
|  | 						</div> | ||||||
|  | 						<div className="w-0 flex-1"> | ||||||
|  | 							<p className="text-sm text-secondary-500 dark:text-white"> | ||||||
|  | 								Scope | ||||||
|  | 							</p> | ||||||
|  | 							<p className="text-xl font-semibold text-secondary-900 dark:text-white"> | ||||||
|  | 								{network.scope} | ||||||
|  | 							</p> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  |  | ||||||
|  | 				<div className="card p-4"> | ||||||
|  | 					<div className="flex items-center"> | ||||||
|  | 						<div className="flex-shrink-0"> | ||||||
|  | 							<Container className="h-5 w-5 text-green-600 mr-2" /> | ||||||
|  | 						</div> | ||||||
|  | 						<div className="w-0 flex-1"> | ||||||
|  | 							<p className="text-sm text-secondary-500 dark:text-white"> | ||||||
|  | 								Containers | ||||||
|  | 							</p> | ||||||
|  | 							<p className="text-xl font-semibold text-secondary-900 dark:text-white"> | ||||||
|  | 								{network.container_count || 0} | ||||||
|  | 							</p> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  |  | ||||||
|  | 				<div className="card p-4"> | ||||||
|  | 					<div className="flex items-center"> | ||||||
|  | 						<div className="flex-shrink-0"> | ||||||
|  | 							<RefreshCw className="h-5 w-5 text-secondary-400 mr-2" /> | ||||||
|  | 						</div> | ||||||
|  | 						<div className="w-0 flex-1"> | ||||||
|  | 							<p className="text-sm text-secondary-500 dark:text-white"> | ||||||
|  | 								Last Checked | ||||||
|  | 							</p> | ||||||
|  | 							<p className="text-sm font-medium text-secondary-900 dark:text-white"> | ||||||
|  | 								{formatRelativeTime(network.last_checked)} | ||||||
|  | 							</p> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  | 			</div> | ||||||
|  |  | ||||||
|  | 			{/* Network Information Card */} | ||||||
|  | 			<div className="card"> | ||||||
|  | 				<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700"> | ||||||
|  | 					<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white"> | ||||||
|  | 						Network Information | ||||||
|  | 					</h3> | ||||||
|  | 				</div> | ||||||
|  | 				<div className="px-6 py-5"> | ||||||
|  | 					<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2"> | ||||||
|  | 						<div> | ||||||
|  | 							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 								Network ID | ||||||
|  | 							</dt> | ||||||
|  | 							<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono break-all"> | ||||||
|  | 								{network.network_id} | ||||||
|  | 							</dd> | ||||||
|  | 						</div> | ||||||
|  | 						<div> | ||||||
|  | 							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 								Name | ||||||
|  | 							</dt> | ||||||
|  | 							<dd className="mt-1 text-sm text-secondary-900 dark:text-white"> | ||||||
|  | 								{network.name} | ||||||
|  | 							</dd> | ||||||
|  | 						</div> | ||||||
|  | 						<div> | ||||||
|  | 							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 								Driver | ||||||
|  | 							</dt> | ||||||
|  | 							<dd className="mt-1"> | ||||||
|  | 								<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"> | ||||||
|  | 									{network.driver} | ||||||
|  | 								</span> | ||||||
|  | 							</dd> | ||||||
|  | 						</div> | ||||||
|  | 						<div> | ||||||
|  | 							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 								Scope | ||||||
|  | 							</dt> | ||||||
|  | 							<dd className="mt-1"> | ||||||
|  | 								<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"> | ||||||
|  | 									{network.scope} | ||||||
|  | 								</span> | ||||||
|  | 							</dd> | ||||||
|  | 						</div> | ||||||
|  | 						<div> | ||||||
|  | 							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 								Containers Attached | ||||||
|  | 							</dt> | ||||||
|  | 							<dd className="mt-1 text-sm text-secondary-900 dark:text-white"> | ||||||
|  | 								{network.container_count || 0} | ||||||
|  | 							</dd> | ||||||
|  | 						</div> | ||||||
|  | 						<div> | ||||||
|  | 							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 								IPv6 Enabled | ||||||
|  | 							</dt> | ||||||
|  | 							<dd className="mt-1"> | ||||||
|  | 								<BooleanBadge value={network.ipv6_enabled} /> | ||||||
|  | 							</dd> | ||||||
|  | 						</div> | ||||||
|  | 						<div> | ||||||
|  | 							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 								Internal | ||||||
|  | 							</dt> | ||||||
|  | 							<dd className="mt-1"> | ||||||
|  | 								<BooleanBadge value={network.internal} /> | ||||||
|  | 							</dd> | ||||||
|  | 						</div> | ||||||
|  | 						<div> | ||||||
|  | 							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 								Attachable | ||||||
|  | 							</dt> | ||||||
|  | 							<dd className="mt-1"> | ||||||
|  | 								<BooleanBadge value={network.attachable} /> | ||||||
|  | 							</dd> | ||||||
|  | 						</div> | ||||||
|  | 						<div> | ||||||
|  | 							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 								Ingress | ||||||
|  | 							</dt> | ||||||
|  | 							<dd className="mt-1"> | ||||||
|  | 								<BooleanBadge value={network.ingress} /> | ||||||
|  | 							</dd> | ||||||
|  | 						</div> | ||||||
|  | 						<div> | ||||||
|  | 							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 								Config Only | ||||||
|  | 							</dt> | ||||||
|  | 							<dd className="mt-1"> | ||||||
|  | 								<BooleanBadge value={network.config_only} /> | ||||||
|  | 							</dd> | ||||||
|  | 						</div> | ||||||
|  | 						{network.created_at && ( | ||||||
|  | 							<div> | ||||||
|  | 								<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 									Created | ||||||
|  | 								</dt> | ||||||
|  | 								<dd className="mt-1 text-sm text-secondary-900 dark:text-white"> | ||||||
|  | 									{formatRelativeTime(network.created_at)} | ||||||
|  | 								</dd> | ||||||
|  | 							</div> | ||||||
|  | 						)} | ||||||
|  | 						<div> | ||||||
|  | 							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 								Last Checked | ||||||
|  | 							</dt> | ||||||
|  | 							<dd className="mt-1 text-sm text-secondary-900 dark:text-white"> | ||||||
|  | 								{formatRelativeTime(network.last_checked)} | ||||||
|  | 							</dd> | ||||||
|  | 						</div> | ||||||
|  | 					</dl> | ||||||
|  | 				</div> | ||||||
|  | 			</div> | ||||||
|  |  | ||||||
|  | 			{/* IPAM Configuration */} | ||||||
|  | 			{network.ipam && ( | ||||||
|  | 				<div className="card"> | ||||||
|  | 					<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700"> | ||||||
|  | 						<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white"> | ||||||
|  | 							IPAM Configuration | ||||||
|  | 						</h3> | ||||||
|  | 						<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 							IP Address Management settings | ||||||
|  | 						</p> | ||||||
|  | 					</div> | ||||||
|  | 					<div className="px-6 py-5"> | ||||||
|  | 						{network.ipam.driver && ( | ||||||
|  | 							<div className="mb-4"> | ||||||
|  | 								<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-1"> | ||||||
|  | 									Driver | ||||||
|  | 								</dt> | ||||||
|  | 								<dd> | ||||||
|  | 									<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"> | ||||||
|  | 										{network.ipam.driver} | ||||||
|  | 									</span> | ||||||
|  | 								</dd> | ||||||
|  | 							</div> | ||||||
|  | 						)} | ||||||
|  | 						{network.ipam.config && network.ipam.config.length > 0 && ( | ||||||
|  | 							<div> | ||||||
|  | 								<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-3"> | ||||||
|  | 									Subnet Configuration | ||||||
|  | 								</dt> | ||||||
|  | 								<div className="space-y-4"> | ||||||
|  | 									{network.ipam.config.map((config, index) => ( | ||||||
|  | 										<div | ||||||
|  | 											key={config.subnet || `config-${index}`} | ||||||
|  | 											className="bg-secondary-50 dark:bg-secondary-900/50 rounded-lg p-4" | ||||||
|  | 										> | ||||||
|  | 											<dl className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2"> | ||||||
|  | 												{config.subnet && ( | ||||||
|  | 													<div> | ||||||
|  | 														<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 															Subnet | ||||||
|  | 														</dt> | ||||||
|  | 														<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono"> | ||||||
|  | 															{config.subnet} | ||||||
|  | 														</dd> | ||||||
|  | 													</div> | ||||||
|  | 												)} | ||||||
|  | 												{config.gateway && ( | ||||||
|  | 													<div> | ||||||
|  | 														<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 															Gateway | ||||||
|  | 														</dt> | ||||||
|  | 														<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono"> | ||||||
|  | 															{config.gateway} | ||||||
|  | 														</dd> | ||||||
|  | 													</div> | ||||||
|  | 												)} | ||||||
|  | 												{config.ip_range && ( | ||||||
|  | 													<div> | ||||||
|  | 														<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 															IP Range | ||||||
|  | 														</dt> | ||||||
|  | 														<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono"> | ||||||
|  | 															{config.ip_range} | ||||||
|  | 														</dd> | ||||||
|  | 													</div> | ||||||
|  | 												)} | ||||||
|  | 												{config.aux_addresses && | ||||||
|  | 													Object.keys(config.aux_addresses).length > 0 && ( | ||||||
|  | 														<div className="sm:col-span-2"> | ||||||
|  | 															<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400 mb-2"> | ||||||
|  | 																Auxiliary Addresses | ||||||
|  | 															</dt> | ||||||
|  | 															<dd className="space-y-1"> | ||||||
|  | 																{Object.entries(config.aux_addresses).map( | ||||||
|  | 																	([key, value]) => ( | ||||||
|  | 																		<div | ||||||
|  | 																			key={key} | ||||||
|  | 																			className="flex items-center text-sm" | ||||||
|  | 																		> | ||||||
|  | 																			<span className="text-secondary-500 dark:text-secondary-400 min-w-[120px]"> | ||||||
|  | 																				{key}: | ||||||
|  | 																			</span> | ||||||
|  | 																			<span className="text-secondary-900 dark:text-white font-mono"> | ||||||
|  | 																				{value} | ||||||
|  | 																			</span> | ||||||
|  | 																		</div> | ||||||
|  | 																	), | ||||||
|  | 																)} | ||||||
|  | 															</dd> | ||||||
|  | 														</div> | ||||||
|  | 													)} | ||||||
|  | 											</dl> | ||||||
|  | 										</div> | ||||||
|  | 									))} | ||||||
|  | 								</div> | ||||||
|  | 							</div> | ||||||
|  | 						)} | ||||||
|  | 						{network.ipam.options && | ||||||
|  | 							Object.keys(network.ipam.options).length > 0 && ( | ||||||
|  | 								<div className="mt-4"> | ||||||
|  | 									<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-2"> | ||||||
|  | 										IPAM Options | ||||||
|  | 									</dt> | ||||||
|  | 									<dd className="space-y-1"> | ||||||
|  | 										{Object.entries(network.ipam.options).map( | ||||||
|  | 											([key, value]) => ( | ||||||
|  | 												<div | ||||||
|  | 													key={key} | ||||||
|  | 													className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0" | ||||||
|  | 												> | ||||||
|  | 													<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]"> | ||||||
|  | 														{key} | ||||||
|  | 													</span> | ||||||
|  | 													<span className="text-sm text-secondary-900 dark:text-white break-all"> | ||||||
|  | 														{value} | ||||||
|  | 													</span> | ||||||
|  | 												</div> | ||||||
|  | 											), | ||||||
|  | 										)} | ||||||
|  | 									</dd> | ||||||
|  | 								</div> | ||||||
|  | 							)} | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  | 			)} | ||||||
|  |  | ||||||
|  | 			{/* Host Information */} | ||||||
|  | 			{host && ( | ||||||
|  | 				<div className="card"> | ||||||
|  | 					<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700"> | ||||||
|  | 						<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center"> | ||||||
|  | 							<Server className="h-5 w-5 mr-2" /> | ||||||
|  | 							Host Information | ||||||
|  | 						</h3> | ||||||
|  | 					</div> | ||||||
|  | 					<div className="px-6 py-5"> | ||||||
|  | 						<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2"> | ||||||
|  | 							<div> | ||||||
|  | 								<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 									Hostname | ||||||
|  | 								</dt> | ||||||
|  | 								<dd className="mt-1 text-sm text-secondary-900 dark:text-white"> | ||||||
|  | 									<Link | ||||||
|  | 										to={`/hosts/${host.id}`} | ||||||
|  | 										className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300" | ||||||
|  | 									> | ||||||
|  | 										{host.hostname} | ||||||
|  | 									</Link> | ||||||
|  | 								</dd> | ||||||
|  | 							</div> | ||||||
|  | 							<div> | ||||||
|  | 								<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 									Operating System | ||||||
|  | 								</dt> | ||||||
|  | 								<dd className="mt-1 text-sm text-secondary-900 dark:text-white"> | ||||||
|  | 									{host.os_name} {host.os_version} | ||||||
|  | 								</dd> | ||||||
|  | 							</div> | ||||||
|  | 						</dl> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  | 			)} | ||||||
|  |  | ||||||
|  | 			{/* Labels */} | ||||||
|  | 			{network.labels && Object.keys(network.labels).length > 0 && ( | ||||||
|  | 				<div className="card"> | ||||||
|  | 					<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700"> | ||||||
|  | 						<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center"> | ||||||
|  | 							<Tag className="h-5 w-5 mr-2" /> | ||||||
|  | 							Labels | ||||||
|  | 						</h3> | ||||||
|  | 					</div> | ||||||
|  | 					<div className="px-6 py-5"> | ||||||
|  | 						<div className="space-y-2"> | ||||||
|  | 							{Object.entries(network.labels).map(([key, value]) => ( | ||||||
|  | 								<div | ||||||
|  | 									key={key} | ||||||
|  | 									className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0" | ||||||
|  | 								> | ||||||
|  | 									<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]"> | ||||||
|  | 										{key} | ||||||
|  | 									</span> | ||||||
|  | 									<span className="text-sm text-secondary-900 dark:text-white break-all"> | ||||||
|  | 										{value} | ||||||
|  | 									</span> | ||||||
|  | 								</div> | ||||||
|  | 							))} | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  | 			)} | ||||||
|  | 		</div> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default NetworkDetail; | ||||||
							
								
								
									
										359
									
								
								frontend/src/pages/docker/VolumeDetail.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										359
									
								
								frontend/src/pages/docker/VolumeDetail.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,359 @@ | |||||||
|  | import { useQuery } from "@tanstack/react-query"; | ||||||
|  | import { | ||||||
|  | 	AlertTriangle, | ||||||
|  | 	ArrowLeft, | ||||||
|  | 	Database, | ||||||
|  | 	HardDrive, | ||||||
|  | 	RefreshCw, | ||||||
|  | 	Server, | ||||||
|  | 	Tag, | ||||||
|  | } from "lucide-react"; | ||||||
|  | import { Link, useParams } from "react-router-dom"; | ||||||
|  | import api, { formatRelativeTime } from "../../utils/api"; | ||||||
|  |  | ||||||
|  | const VolumeDetail = () => { | ||||||
|  | 	const { id } = useParams(); | ||||||
|  |  | ||||||
|  | 	const { data, isLoading, error } = useQuery({ | ||||||
|  | 		queryKey: ["docker", "volume", id], | ||||||
|  | 		queryFn: async () => { | ||||||
|  | 			const response = await api.get(`/docker/volumes/${id}`); | ||||||
|  | 			return response.data; | ||||||
|  | 		}, | ||||||
|  | 		refetchInterval: 30000, | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	const volume = data?.volume; | ||||||
|  | 	const host = data?.host; | ||||||
|  |  | ||||||
|  | 	if (isLoading) { | ||||||
|  | 		return ( | ||||||
|  | 			<div className="flex items-center justify-center min-h-screen"> | ||||||
|  | 				<RefreshCw className="h-8 w-8 animate-spin text-secondary-400" /> | ||||||
|  | 			</div> | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if (error || !volume) { | ||||||
|  | 		return ( | ||||||
|  | 			<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> | ||||||
|  | 				<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"> | ||||||
|  | 					<div className="flex"> | ||||||
|  | 						<AlertTriangle className="h-5 w-5 text-red-400" /> | ||||||
|  | 						<div className="ml-3"> | ||||||
|  | 							<h3 className="text-sm font-medium text-red-800 dark:text-red-200"> | ||||||
|  | 								Volume not found | ||||||
|  | 							</h3> | ||||||
|  | 							<p className="mt-2 text-sm text-red-700 dark:text-red-300"> | ||||||
|  | 								The volume you're looking for doesn't exist or has been removed. | ||||||
|  | 							</p> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  | 				<Link | ||||||
|  | 					to="/docker" | ||||||
|  | 					className="mt-4 inline-flex items-center text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300" | ||||||
|  | 				> | ||||||
|  | 					<ArrowLeft className="h-4 w-4 mr-2" /> | ||||||
|  | 					Back to Docker | ||||||
|  | 				</Link> | ||||||
|  | 			</div> | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	const formatBytes = (bytes) => { | ||||||
|  | 		if (bytes === null || bytes === undefined) return "N/A"; | ||||||
|  | 		const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; | ||||||
|  | 		if (bytes === 0) return "0 Bytes"; | ||||||
|  | 		const i = Math.floor(Math.log(bytes) / Math.log(1024)); | ||||||
|  | 		return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`; | ||||||
|  | 	}; | ||||||
|  |  | ||||||
|  | 	return ( | ||||||
|  | 		<div className="space-y-6"> | ||||||
|  | 			{/* Header */} | ||||||
|  | 			<div> | ||||||
|  | 				<Link | ||||||
|  | 					to="/docker" | ||||||
|  | 					className="inline-flex items-center text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 mb-4" | ||||||
|  | 				> | ||||||
|  | 					<ArrowLeft className="h-4 w-4 mr-2" /> | ||||||
|  | 					Back to Docker | ||||||
|  | 				</Link> | ||||||
|  | 				<div className="flex items-center"> | ||||||
|  | 					<HardDrive className="h-8 w-8 text-secondary-400 mr-3" /> | ||||||
|  | 					<div> | ||||||
|  | 						<h1 className="text-2xl font-bold text-secondary-900 dark:text-white"> | ||||||
|  | 							{volume.name} | ||||||
|  | 						</h1> | ||||||
|  | 						<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400"> | ||||||
|  | 							Volume ID: {volume.volume_id} | ||||||
|  | 						</p> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  | 			</div> | ||||||
|  |  | ||||||
|  | 			{/* Overview Cards */} | ||||||
|  | 			<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4"> | ||||||
|  | 				<div className="card p-4"> | ||||||
|  | 					<div className="flex items-center"> | ||||||
|  | 						<div className="flex-shrink-0"> | ||||||
|  | 							<HardDrive className="h-5 w-5 text-blue-600 mr-2" /> | ||||||
|  | 						</div> | ||||||
|  | 						<div className="w-0 flex-1"> | ||||||
|  | 							<p className="text-sm text-secondary-500 dark:text-white"> | ||||||
|  | 								Driver | ||||||
|  | 							</p> | ||||||
|  | 							<p className="text-xl font-semibold text-secondary-900 dark:text-white"> | ||||||
|  | 								{volume.driver} | ||||||
|  | 							</p> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  |  | ||||||
|  | 				<div className="card p-4"> | ||||||
|  | 					<div className="flex items-center"> | ||||||
|  | 						<div className="flex-shrink-0"> | ||||||
|  | 							<Database className="h-5 w-5 text-purple-600 mr-2" /> | ||||||
|  | 						</div> | ||||||
|  | 						<div className="w-0 flex-1"> | ||||||
|  | 							<p className="text-sm text-secondary-500 dark:text-white">Size</p> | ||||||
|  | 							<p className="text-xl font-semibold text-secondary-900 dark:text-white"> | ||||||
|  | 								{formatBytes(volume.size_bytes)} | ||||||
|  | 							</p> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  |  | ||||||
|  | 				<div className="card p-4"> | ||||||
|  | 					<div className="flex items-center"> | ||||||
|  | 						<div className="flex-shrink-0"> | ||||||
|  | 							<Server className="h-5 w-5 text-green-600 mr-2" /> | ||||||
|  | 						</div> | ||||||
|  | 						<div className="w-0 flex-1"> | ||||||
|  | 							<p className="text-sm text-secondary-500 dark:text-white"> | ||||||
|  | 								Containers | ||||||
|  | 							</p> | ||||||
|  | 							<p className="text-xl font-semibold text-secondary-900 dark:text-white"> | ||||||
|  | 								{volume.ref_count || 0} | ||||||
|  | 							</p> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  |  | ||||||
|  | 				<div className="card p-4"> | ||||||
|  | 					<div className="flex items-center"> | ||||||
|  | 						<div className="flex-shrink-0"> | ||||||
|  | 							<RefreshCw className="h-5 w-5 text-secondary-400 mr-2" /> | ||||||
|  | 						</div> | ||||||
|  | 						<div className="w-0 flex-1"> | ||||||
|  | 							<p className="text-sm text-secondary-500 dark:text-white"> | ||||||
|  | 								Last Checked | ||||||
|  | 							</p> | ||||||
|  | 							<p className="text-sm font-medium text-secondary-900 dark:text-white"> | ||||||
|  | 								{formatRelativeTime(volume.last_checked)} | ||||||
|  | 							</p> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  | 			</div> | ||||||
|  |  | ||||||
|  | 			{/* Volume Information Card */} | ||||||
|  | 			<div className="card"> | ||||||
|  | 				<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700"> | ||||||
|  | 					<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white"> | ||||||
|  | 						Volume Information | ||||||
|  | 					</h3> | ||||||
|  | 				</div> | ||||||
|  | 				<div className="px-6 py-5"> | ||||||
|  | 					<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2"> | ||||||
|  | 						<div> | ||||||
|  | 							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 								Volume ID | ||||||
|  | 							</dt> | ||||||
|  | 							<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono"> | ||||||
|  | 								{volume.volume_id} | ||||||
|  | 							</dd> | ||||||
|  | 						</div> | ||||||
|  | 						<div> | ||||||
|  | 							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 								Name | ||||||
|  | 							</dt> | ||||||
|  | 							<dd className="mt-1 text-sm text-secondary-900 dark:text-white"> | ||||||
|  | 								{volume.name} | ||||||
|  | 							</dd> | ||||||
|  | 						</div> | ||||||
|  | 						<div> | ||||||
|  | 							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 								Driver | ||||||
|  | 							</dt> | ||||||
|  | 							<dd className="mt-1"> | ||||||
|  | 								<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"> | ||||||
|  | 									{volume.driver} | ||||||
|  | 								</span> | ||||||
|  | 							</dd> | ||||||
|  | 						</div> | ||||||
|  | 						<div> | ||||||
|  | 							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 								Scope | ||||||
|  | 							</dt> | ||||||
|  | 							<dd className="mt-1"> | ||||||
|  | 								<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"> | ||||||
|  | 									{volume.scope} | ||||||
|  | 								</span> | ||||||
|  | 							</dd> | ||||||
|  | 						</div> | ||||||
|  | 						<div> | ||||||
|  | 							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 								Size | ||||||
|  | 							</dt> | ||||||
|  | 							<dd className="mt-1 text-sm text-secondary-900 dark:text-white"> | ||||||
|  | 								{formatBytes(volume.size_bytes)} | ||||||
|  | 							</dd> | ||||||
|  | 						</div> | ||||||
|  | 						<div> | ||||||
|  | 							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 								Containers Using | ||||||
|  | 							</dt> | ||||||
|  | 							<dd className="mt-1 text-sm text-secondary-900 dark:text-white"> | ||||||
|  | 								{volume.ref_count || 0} | ||||||
|  | 							</dd> | ||||||
|  | 						</div> | ||||||
|  | 						{volume.mountpoint && ( | ||||||
|  | 							<div className="sm:col-span-2"> | ||||||
|  | 								<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 									Mount Point | ||||||
|  | 								</dt> | ||||||
|  | 								<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono break-all"> | ||||||
|  | 									{volume.mountpoint} | ||||||
|  | 								</dd> | ||||||
|  | 							</div> | ||||||
|  | 						)} | ||||||
|  | 						{volume.renderer && ( | ||||||
|  | 							<div> | ||||||
|  | 								<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 									Renderer | ||||||
|  | 								</dt> | ||||||
|  | 								<dd className="mt-1 text-sm text-secondary-900 dark:text-white"> | ||||||
|  | 									{volume.renderer} | ||||||
|  | 								</dd> | ||||||
|  | 							</div> | ||||||
|  | 						)} | ||||||
|  | 						<div> | ||||||
|  | 							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 								Created | ||||||
|  | 							</dt> | ||||||
|  | 							<dd className="mt-1 text-sm text-secondary-900 dark:text-white"> | ||||||
|  | 								{formatRelativeTime(volume.created_at)} | ||||||
|  | 							</dd> | ||||||
|  | 						</div> | ||||||
|  | 						<div> | ||||||
|  | 							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 								Last Checked | ||||||
|  | 							</dt> | ||||||
|  | 							<dd className="mt-1 text-sm text-secondary-900 dark:text-white"> | ||||||
|  | 								{formatRelativeTime(volume.last_checked)} | ||||||
|  | 							</dd> | ||||||
|  | 						</div> | ||||||
|  | 					</dl> | ||||||
|  | 				</div> | ||||||
|  | 			</div> | ||||||
|  |  | ||||||
|  | 			{/* Host Information */} | ||||||
|  | 			{host && ( | ||||||
|  | 				<div className="card"> | ||||||
|  | 					<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700"> | ||||||
|  | 						<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center"> | ||||||
|  | 							<Server className="h-5 w-5 mr-2" /> | ||||||
|  | 							Host Information | ||||||
|  | 						</h3> | ||||||
|  | 					</div> | ||||||
|  | 					<div className="px-6 py-5"> | ||||||
|  | 						<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2"> | ||||||
|  | 							<div> | ||||||
|  | 								<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 									Hostname | ||||||
|  | 								</dt> | ||||||
|  | 								<dd className="mt-1 text-sm text-secondary-900 dark:text-white"> | ||||||
|  | 									<Link | ||||||
|  | 										to={`/hosts/${host.id}`} | ||||||
|  | 										className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300" | ||||||
|  | 									> | ||||||
|  | 										{host.hostname} | ||||||
|  | 									</Link> | ||||||
|  | 								</dd> | ||||||
|  | 							</div> | ||||||
|  | 							<div> | ||||||
|  | 								<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400"> | ||||||
|  | 									Operating System | ||||||
|  | 								</dt> | ||||||
|  | 								<dd className="mt-1 text-sm text-secondary-900 dark:text-white"> | ||||||
|  | 									{host.os_name} {host.os_version} | ||||||
|  | 								</dd> | ||||||
|  | 							</div> | ||||||
|  | 						</dl> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  | 			)} | ||||||
|  |  | ||||||
|  | 			{/* Labels */} | ||||||
|  | 			{volume.labels && Object.keys(volume.labels).length > 0 && ( | ||||||
|  | 				<div className="card"> | ||||||
|  | 					<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700"> | ||||||
|  | 						<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center"> | ||||||
|  | 							<Tag className="h-5 w-5 mr-2" /> | ||||||
|  | 							Labels | ||||||
|  | 						</h3> | ||||||
|  | 					</div> | ||||||
|  | 					<div className="px-6 py-5"> | ||||||
|  | 						<div className="space-y-2"> | ||||||
|  | 							{Object.entries(volume.labels).map(([key, value]) => ( | ||||||
|  | 								<div | ||||||
|  | 									key={key} | ||||||
|  | 									className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0" | ||||||
|  | 								> | ||||||
|  | 									<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]"> | ||||||
|  | 										{key} | ||||||
|  | 									</span> | ||||||
|  | 									<span className="text-sm text-secondary-900 dark:text-white break-all"> | ||||||
|  | 										{value} | ||||||
|  | 									</span> | ||||||
|  | 								</div> | ||||||
|  | 							))} | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  | 			)} | ||||||
|  |  | ||||||
|  | 			{/* Options */} | ||||||
|  | 			{volume.options && Object.keys(volume.options).length > 0 && ( | ||||||
|  | 				<div className="card"> | ||||||
|  | 					<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700"> | ||||||
|  | 						<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white"> | ||||||
|  | 							Volume Options | ||||||
|  | 						</h3> | ||||||
|  | 					</div> | ||||||
|  | 					<div className="px-6 py-5"> | ||||||
|  | 						<div className="space-y-2"> | ||||||
|  | 							{Object.entries(volume.options).map(([key, value]) => ( | ||||||
|  | 								<div | ||||||
|  | 									key={key} | ||||||
|  | 									className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0" | ||||||
|  | 								> | ||||||
|  | 									<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]"> | ||||||
|  | 										{key} | ||||||
|  | 									</span> | ||||||
|  | 									<span className="text-sm text-secondary-900 dark:text-white break-all"> | ||||||
|  | 										{value} | ||||||
|  | 									</span> | ||||||
|  | 								</div> | ||||||
|  | 							))} | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  | 			)} | ||||||
|  | 		</div> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default VolumeDetail; | ||||||
| @@ -746,239 +746,126 @@ const Integrations = () => { | |||||||
| 									</div> | 									</div> | ||||||
| 									<div> | 									<div> | ||||||
| 										<h3 className="text-lg font-semibold text-secondary-900 dark:text-white"> | 										<h3 className="text-lg font-semibold text-secondary-900 dark:text-white"> | ||||||
| 											Docker Container Monitoring | 											Docker Inventory Collection | ||||||
| 										</h3> | 										</h3> | ||||||
| 										<p className="text-sm text-secondary-600 dark:text-secondary-400"> | 										<p className="text-sm text-secondary-600 dark:text-secondary-400"> | ||||||
| 											Monitor Docker containers and images for available updates | 											Docker monitoring is now built into the PatchMon Go agent | ||||||
| 										</p> | 										</p> | ||||||
| 									</div> | 									</div> | ||||||
| 								</div> | 								</div> | ||||||
|  |  | ||||||
| 								{/* Installation Instructions */} | 								{/* Info Message */} | ||||||
| 								<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6"> | 								<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6"> | ||||||
| 									<h3 className="text-lg font-semibold text-primary-900 dark:text-primary-200 mb-4"> | 									<div className="flex items-start gap-3"> | ||||||
| 										Agent Installation | 										<CheckCircle className="h-5 w-5 text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5" /> | ||||||
| 									</h3> | 										<div> | ||||||
| 									<ol className="list-decimal list-inside space-y-3 text-sm text-primary-800 dark:text-primary-300"> | 											<h4 className="text-md font-semibold text-primary-900 dark:text-primary-200 mb-2"> | ||||||
|  | 												Automatic Docker Discovery | ||||||
|  | 											</h4> | ||||||
|  | 											<p className="text-sm text-primary-800 dark:text-primary-300 mb-3"> | ||||||
|  | 												The PatchMon Go agent automatically discovers Docker | ||||||
|  | 												when it's available on your host and collects | ||||||
|  | 												comprehensive inventory information: | ||||||
|  | 											</p> | ||||||
|  | 											<ul className="list-disc list-inside space-y-2 text-sm text-primary-800 dark:text-primary-300 ml-2"> | ||||||
|  | 												<li> | ||||||
|  | 													<strong>Containers</strong> - Running and stopped | ||||||
|  | 													containers with status, images, ports, and labels | ||||||
|  | 												</li> | ||||||
|  | 												<li> | ||||||
|  | 													<strong>Images</strong> - All Docker images with | ||||||
|  | 													repository, tags, sizes, and sources | ||||||
|  | 												</li> | ||||||
|  | 												<li> | ||||||
|  | 													<strong>Volumes</strong> - Named and anonymous volumes | ||||||
|  | 													with drivers, mountpoints, and usage | ||||||
|  | 												</li> | ||||||
|  | 												<li> | ||||||
|  | 													<strong>Networks</strong> - Docker networks with | ||||||
|  | 													drivers, IPAM configuration, and connected containers | ||||||
|  | 												</li> | ||||||
|  | 												<li> | ||||||
|  | 													<strong>Real-time Updates</strong> - Container status | ||||||
|  | 													changes are pushed instantly via WebSocket | ||||||
|  | 												</li> | ||||||
|  | 											</ul> | ||||||
|  | 										</div> | ||||||
|  | 									</div> | ||||||
|  | 								</div> | ||||||
|  |  | ||||||
|  | 								{/* How It Works */} | ||||||
|  | 								<div className="bg-white dark:bg-secondary-900 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6"> | ||||||
|  | 									<h4 className="text-md font-semibold text-secondary-900 dark:text-white mb-4"> | ||||||
|  | 										How It Works | ||||||
|  | 									</h4> | ||||||
|  | 									<ol className="list-decimal list-inside space-y-3 text-sm text-secondary-700 dark:text-secondary-300"> | ||||||
| 										<li> | 										<li> | ||||||
| 											Make sure you have the PatchMon credentials file set up on | 											Install the PatchMon Go agent on your host (see the Hosts | ||||||
| 											your host ( | 											page for installation instructions) | ||||||
| 											<code className="bg-primary-100 dark:bg-primary-900/40 px-1 py-0.5 rounded text-xs"> |  | ||||||
| 												/etc/patchmon/credentials |  | ||||||
| 											</code> |  | ||||||
| 											) |  | ||||||
| 										</li> | 										</li> | ||||||
| 										<li> | 										<li> | ||||||
| 											SSH into your Docker host where you want to monitor | 											The agent automatically detects if Docker is installed and | ||||||
| 											containers | 											running on the host | ||||||
| 										</li> | 										</li> | ||||||
| 										<li>Run the installation command below</li> |  | ||||||
| 										<li> | 										<li> | ||||||
| 											The agent will automatically collect Docker container and | 											During each collection cycle, the agent gathers Docker | ||||||
| 											image information every 5 minutes | 											inventory data and sends it to the PatchMon server | ||||||
|  | 										</li> | ||||||
|  | 										<li> | ||||||
|  | 											View your complete Docker inventory (containers, images, | ||||||
|  | 											volumes, networks) in the{" "} | ||||||
|  | 											<a | ||||||
|  | 												href="/docker" | ||||||
|  | 												className="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 underline" | ||||||
|  | 											> | ||||||
|  | 												Docker page | ||||||
|  | 											</a> | ||||||
|  | 										</li> | ||||||
|  | 										<li> | ||||||
|  | 											Container status changes are pushed to the server in | ||||||
|  | 											real-time via WebSocket connection | ||||||
| 										</li> | 										</li> | ||||||
| 										<li>View your Docker inventory in the Docker page</li> |  | ||||||
| 									</ol> | 									</ol> | ||||||
| 								</div> | 								</div> | ||||||
|  |  | ||||||
| 								{/* Installation Command */} | 								{/* No Configuration Required */} | ||||||
| 								<div className="bg-white dark:bg-secondary-900 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6"> | 								<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4"> | ||||||
| 									<h4 className="text-md font-semibold text-secondary-900 dark:text-white mb-3"> |  | ||||||
| 										Quick Installation (One-Line Command) |  | ||||||
| 									</h4> |  | ||||||
| 									<div className="space-y-3"> |  | ||||||
| 										<div> |  | ||||||
| 											<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"> |  | ||||||
| 												Download and install the Docker agent: |  | ||||||
| 											</div> |  | ||||||
| 											<div className="flex items-center gap-2"> |  | ||||||
| 												<input |  | ||||||
| 													type="text" |  | ||||||
| 													value={`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent" && chmod +x /usr/local/bin/patchmon-docker-agent.sh && echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -`} |  | ||||||
| 													readOnly |  | ||||||
| 													className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs" |  | ||||||
| 												/> |  | ||||||
| 												<button |  | ||||||
| 													type="button" |  | ||||||
| 													onClick={() => |  | ||||||
| 														copy_to_clipboard( |  | ||||||
| 															`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent" && chmod +x /usr/local/bin/patchmon-docker-agent.sh && echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -`, |  | ||||||
| 															"docker-install", |  | ||||||
| 														) |  | ||||||
| 													} |  | ||||||
| 													className="btn-primary flex items-center gap-1 px-3 py-2 whitespace-nowrap" |  | ||||||
| 												> |  | ||||||
| 													{copy_success["docker-install"] ? ( |  | ||||||
| 														<> |  | ||||||
| 															<CheckCircle className="h-4 w-4" /> |  | ||||||
| 															Copied |  | ||||||
| 														</> |  | ||||||
| 													) : ( |  | ||||||
| 														<> |  | ||||||
| 															<Copy className="h-4 w-4" /> |  | ||||||
| 															Copy |  | ||||||
| 														</> |  | ||||||
| 													)} |  | ||||||
| 												</button> |  | ||||||
| 											</div> |  | ||||||
| 											<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-2"> |  | ||||||
| 												💡 This will download the agent, make it executable, and |  | ||||||
| 												set up a cron job to run every 5 minutes |  | ||||||
| 											</p> |  | ||||||
| 										</div> |  | ||||||
| 									</div> |  | ||||||
| 								</div> |  | ||||||
|  |  | ||||||
| 								{/* Manual Installation Steps */} |  | ||||||
| 								<div className="bg-white dark:bg-secondary-900 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6"> |  | ||||||
| 									<h4 className="text-md font-semibold text-secondary-900 dark:text-white mb-3"> |  | ||||||
| 										Manual Installation Steps |  | ||||||
| 									</h4> |  | ||||||
| 									<div className="space-y-4"> |  | ||||||
| 										<div> |  | ||||||
| 											<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2"> |  | ||||||
| 												<strong>Step 1:</strong> Download the agent |  | ||||||
| 											</p> |  | ||||||
| 											<div className="flex items-center gap-2"> |  | ||||||
| 												<input |  | ||||||
| 													type="text" |  | ||||||
| 													value={`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent"`} |  | ||||||
| 													readOnly |  | ||||||
| 													className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs" |  | ||||||
| 												/> |  | ||||||
| 												<button |  | ||||||
| 													type="button" |  | ||||||
| 													onClick={() => |  | ||||||
| 														copy_to_clipboard( |  | ||||||
| 															`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent"`, |  | ||||||
| 															"docker-download", |  | ||||||
| 														) |  | ||||||
| 													} |  | ||||||
| 													className="btn-primary p-2" |  | ||||||
| 												> |  | ||||||
| 													{copy_success["docker-download"] ? ( |  | ||||||
| 														<CheckCircle className="h-4 w-4" /> |  | ||||||
| 													) : ( |  | ||||||
| 														<Copy className="h-4 w-4" /> |  | ||||||
| 													)} |  | ||||||
| 												</button> |  | ||||||
| 											</div> |  | ||||||
| 										</div> |  | ||||||
|  |  | ||||||
| 										<div> |  | ||||||
| 											<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2"> |  | ||||||
| 												<strong>Step 2:</strong> Make it executable |  | ||||||
| 											</p> |  | ||||||
| 											<div className="flex items-center gap-2"> |  | ||||||
| 												<input |  | ||||||
| 													type="text" |  | ||||||
| 													value="chmod +x /usr/local/bin/patchmon-docker-agent.sh" |  | ||||||
| 													readOnly |  | ||||||
| 													className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs" |  | ||||||
| 												/> |  | ||||||
| 												<button |  | ||||||
| 													type="button" |  | ||||||
| 													onClick={() => |  | ||||||
| 														copy_to_clipboard( |  | ||||||
| 															"chmod +x /usr/local/bin/patchmon-docker-agent.sh", |  | ||||||
| 															"docker-chmod", |  | ||||||
| 														) |  | ||||||
| 													} |  | ||||||
| 													className="btn-primary p-2" |  | ||||||
| 												> |  | ||||||
| 													{copy_success["docker-chmod"] ? ( |  | ||||||
| 														<CheckCircle className="h-4 w-4" /> |  | ||||||
| 													) : ( |  | ||||||
| 														<Copy className="h-4 w-4" /> |  | ||||||
| 													)} |  | ||||||
| 												</button> |  | ||||||
| 											</div> |  | ||||||
| 										</div> |  | ||||||
|  |  | ||||||
| 										<div> |  | ||||||
| 											<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2"> |  | ||||||
| 												<strong>Step 3:</strong> Test the agent |  | ||||||
| 											</p> |  | ||||||
| 											<div className="flex items-center gap-2"> |  | ||||||
| 												<input |  | ||||||
| 													type="text" |  | ||||||
| 													value="/usr/local/bin/patchmon-docker-agent.sh collect" |  | ||||||
| 													readOnly |  | ||||||
| 													className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs" |  | ||||||
| 												/> |  | ||||||
| 												<button |  | ||||||
| 													type="button" |  | ||||||
| 													onClick={() => |  | ||||||
| 														copy_to_clipboard( |  | ||||||
| 															"/usr/local/bin/patchmon-docker-agent.sh collect", |  | ||||||
| 															"docker-test", |  | ||||||
| 														) |  | ||||||
| 													} |  | ||||||
| 													className="btn-primary p-2" |  | ||||||
| 												> |  | ||||||
| 													{copy_success["docker-test"] ? ( |  | ||||||
| 														<CheckCircle className="h-4 w-4" /> |  | ||||||
| 													) : ( |  | ||||||
| 														<Copy className="h-4 w-4" /> |  | ||||||
| 													)} |  | ||||||
| 												</button> |  | ||||||
| 											</div> |  | ||||||
| 										</div> |  | ||||||
|  |  | ||||||
| 										<div> |  | ||||||
| 											<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2"> |  | ||||||
| 												<strong>Step 4:</strong> Set up automatic collection |  | ||||||
| 												(every 5 minutes) |  | ||||||
| 											</p> |  | ||||||
| 											<div className="flex items-center gap-2"> |  | ||||||
| 												<input |  | ||||||
| 													type="text" |  | ||||||
| 													value='echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -' |  | ||||||
| 													readOnly |  | ||||||
| 													className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs" |  | ||||||
| 												/> |  | ||||||
| 												<button |  | ||||||
| 													type="button" |  | ||||||
| 													onClick={() => |  | ||||||
| 														copy_to_clipboard( |  | ||||||
| 															'echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -', |  | ||||||
| 															"docker-cron", |  | ||||||
| 														) |  | ||||||
| 													} |  | ||||||
| 													className="btn-primary p-2" |  | ||||||
| 												> |  | ||||||
| 													{copy_success["docker-cron"] ? ( |  | ||||||
| 														<CheckCircle className="h-4 w-4" /> |  | ||||||
| 													) : ( |  | ||||||
| 														<Copy className="h-4 w-4" /> |  | ||||||
| 													)} |  | ||||||
| 												</button> |  | ||||||
| 											</div> |  | ||||||
| 										</div> |  | ||||||
| 									</div> |  | ||||||
| 								</div> |  | ||||||
|  |  | ||||||
| 								{/* Prerequisites */} |  | ||||||
| 								<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4"> |  | ||||||
| 									<div className="flex items-start gap-2"> | 									<div className="flex items-start gap-2"> | ||||||
| 										<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" /> | 										<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" /> | ||||||
| 										<div className="text-sm text-yellow-800 dark:text-yellow-200"> | 										<div className="text-sm text-green-800 dark:text-green-200"> | ||||||
| 											<p className="font-semibold mb-2">Prerequisites:</p> | 											<p className="font-semibold mb-1"> | ||||||
|  | 												No Additional Configuration Required | ||||||
|  | 											</p> | ||||||
|  | 											<p> | ||||||
|  | 												Once the Go agent is installed and Docker is running on | ||||||
|  | 												your host, Docker inventory collection happens | ||||||
|  | 												automatically. No separate Docker agent or cron jobs | ||||||
|  | 												needed. | ||||||
|  | 											</p> | ||||||
|  | 										</div> | ||||||
|  | 									</div> | ||||||
|  | 								</div> | ||||||
|  |  | ||||||
|  | 								{/* Requirements */} | ||||||
|  | 								<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4"> | ||||||
|  | 									<div className="flex items-start gap-2"> | ||||||
|  | 										<AlertCircle className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" /> | ||||||
|  | 										<div className="text-sm text-blue-800 dark:text-blue-200"> | ||||||
|  | 											<p className="font-semibold mb-2">Requirements:</p> | ||||||
| 											<ul className="list-disc list-inside space-y-1 ml-2"> | 											<ul className="list-disc list-inside space-y-1 ml-2"> | ||||||
|  | 												<li>PatchMon Go agent must be installed and running</li> | ||||||
|  | 												<li>Docker daemon must be installed and running</li> | ||||||
| 												<li> | 												<li> | ||||||
| 													Docker must be installed and running on the host | 													Agent must have access to the Docker socket ( | ||||||
| 												</li> | 													<code className="bg-blue-100 dark:bg-blue-900/40 px-1 py-0.5 rounded text-xs"> | ||||||
| 												<li> | 														/var/run/docker.sock | ||||||
| 													PatchMon credentials file must exist at{" "} |  | ||||||
| 													<code className="bg-yellow-100 dark:bg-yellow-900/40 px-1 py-0.5 rounded text-xs"> |  | ||||||
| 														/etc/patchmon/credentials |  | ||||||
| 													</code> | 													</code> | ||||||
|  | 													) | ||||||
| 												</li> | 												</li> | ||||||
| 												<li> | 												<li> | ||||||
| 													The host must have network access to your PatchMon | 													Typically requires running the agent as root or with | ||||||
| 													server | 													Docker group permissions | ||||||
| 												</li> | 												</li> | ||||||
| 												<li>The agent must run as root (or with sudo)</li> |  | ||||||
| 											</ul> | 											</ul> | ||||||
| 										</div> | 										</div> | ||||||
| 									</div> | 									</div> | ||||||
|   | |||||||
| @@ -215,8 +215,8 @@ const SettingsHostGroups = () => { | |||||||
| 													title={`View hosts in ${group.name}`} | 													title={`View hosts in ${group.name}`} | ||||||
| 												> | 												> | ||||||
| 													<Server className="h-4 w-4 mr-2" /> | 													<Server className="h-4 w-4 mr-2" /> | ||||||
| 													{group._count.hosts} host | 													{group._count?.hosts || 0} host | ||||||
| 													{group._count.hosts !== 1 ? "s" : ""} | 													{group._count?.hosts !== 1 ? "s" : ""} | ||||||
| 												</button> | 												</button> | ||||||
| 											</td> | 											</td> | ||||||
| 											<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> | 											<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> | ||||||
| @@ -539,9 +539,18 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => { | |||||||
|  |  | ||||||
| // Delete Confirmation Modal | // Delete Confirmation Modal | ||||||
| const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => { | const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => { | ||||||
|  | 	// Fetch hosts for this group | ||||||
|  | 	const { data: hostsData } = useQuery({ | ||||||
|  | 		queryKey: ["hostGroupHosts", group?.id], | ||||||
|  | 		queryFn: () => hostGroupsAPI.getHosts(group.id).then((res) => res.data), | ||||||
|  | 		enabled: !!group && group._count?.hosts > 0, | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	const hosts = hostsData || []; | ||||||
|  |  | ||||||
| 	return ( | 	return ( | ||||||
| 		<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> | 		<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 w-full max-w-md"> | 			<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto"> | ||||||
| 				<div className="flex items-center gap-3 mb-4"> | 				<div className="flex items-center gap-3 mb-4"> | ||||||
| 					<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center"> | 					<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center"> | ||||||
| 						<AlertTriangle className="h-5 w-5 text-danger-600" /> | 						<AlertTriangle className="h-5 w-5 text-danger-600" /> | ||||||
| @@ -561,14 +570,32 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => { | |||||||
| 						Are you sure you want to delete the host group{" "} | 						Are you sure you want to delete the host group{" "} | ||||||
| 						<span className="font-semibold">"{group.name}"</span>? | 						<span className="font-semibold">"{group.name}"</span>? | ||||||
| 					</p> | 					</p> | ||||||
| 					{group._count.hosts > 0 && ( | 					{group._count?.hosts > 0 && ( | ||||||
| 						<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-md"> | 						<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-md"> | ||||||
| 							<p className="text-sm text-blue-800"> | 							<p className="text-sm text-blue-800 mb-2"> | ||||||
| 								<strong>Note:</strong> This group contains {group._count.hosts}{" "} | 								<strong>Note:</strong> This group contains {group._count?.hosts}{" "} | ||||||
| 								host | 								host | ||||||
| 								{group._count.hosts !== 1 ? "s" : ""}. These hosts will be moved | 								{group._count?.hosts !== 1 ? "s" : ""}. These hosts will be | ||||||
| 								to "No group" after deletion. | 								moved to "No group" after deletion. | ||||||
| 							</p> | 							</p> | ||||||
|  | 							{hosts.length > 0 && ( | ||||||
|  | 								<div className="mt-2"> | ||||||
|  | 									<p className="text-xs font-medium text-blue-900 mb-1"> | ||||||
|  | 										Hosts in this group: | ||||||
|  | 									</p> | ||||||
|  | 									<div className="max-h-32 overflow-y-auto bg-blue-100 rounded p-2"> | ||||||
|  | 										{hosts.map((host) => ( | ||||||
|  | 											<div | ||||||
|  | 												key={host.id} | ||||||
|  | 												className="text-xs text-blue-900 flex items-center gap-1" | ||||||
|  | 											> | ||||||
|  | 												<Server className="h-3 w-3" /> | ||||||
|  | 												{host.friendly_name || host.hostname} | ||||||
|  | 											</div> | ||||||
|  | 										))} | ||||||
|  | 									</div> | ||||||
|  | 								</div> | ||||||
|  | 							)} | ||||||
| 						</div> | 						</div> | ||||||
| 					)} | 					)} | ||||||
| 				</div> | 				</div> | ||||||
|   | |||||||
							
								
								
									
										413
									
								
								frontend/src/pages/settings/SettingsMetrics.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										413
									
								
								frontend/src/pages/settings/SettingsMetrics.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,413 @@ | |||||||
|  | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | ||||||
|  | import { | ||||||
|  | 	AlertCircle, | ||||||
|  | 	BarChart3, | ||||||
|  | 	BookOpen, | ||||||
|  | 	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> | ||||||
|  |  | ||||||
|  | 					{/* More Information Button */} | ||||||
|  | 					<div className="mt-4 pt-4 border-t border-blue-200 dark:border-blue-700"> | ||||||
|  | 						<a | ||||||
|  | 							href="https://docs.patchmon.net/books/patchmon-application-documentation/page/metrics-collection-information" | ||||||
|  | 							target="_blank" | ||||||
|  | 							rel="noopener noreferrer" | ||||||
|  | 							className="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 rounded-md hover:bg-blue-200 dark:hover:bg-blue-900/70 transition-colors" | ||||||
|  | 						> | ||||||
|  | 							<BookOpen className="h-4 w-4 mr-2" /> | ||||||
|  | 							More Information | ||||||
|  | 						</a> | ||||||
|  | 					</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", | ||||||
| 	}, | 	}, | ||||||
| @@ -19,6 +19,30 @@ api.interceptors.request.use( | |||||||
| 		if (token) { | 		if (token) { | ||||||
| 			config.headers.Authorization = `Bearer ${token}`; | 			config.headers.Authorization = `Bearer ${token}`; | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | 		// Add device ID for TFA remember-me functionality | ||||||
|  | 		// This uniquely identifies the browser profile (normal vs incognito) | ||||||
|  | 		let deviceId = localStorage.getItem("device_id"); | ||||||
|  | 		if (!deviceId) { | ||||||
|  | 			// Generate a unique device ID and store it | ||||||
|  | 			// Use crypto.randomUUID() if available, otherwise generate a UUID v4 manually | ||||||
|  | 			if (typeof crypto !== "undefined" && crypto.randomUUID) { | ||||||
|  | 				deviceId = crypto.randomUUID(); | ||||||
|  | 			} else { | ||||||
|  | 				// Fallback: Generate UUID v4 manually | ||||||
|  | 				deviceId = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace( | ||||||
|  | 					/[xy]/g, | ||||||
|  | 					(c) => { | ||||||
|  | 						const r = (Math.random() * 16) | 0; | ||||||
|  | 						const v = c === "x" ? r : (r & 0x3) | 0x8; | ||||||
|  | 						return v.toString(16); | ||||||
|  | 					}, | ||||||
|  | 				); | ||||||
|  | 			} | ||||||
|  | 			localStorage.setItem("device_id", deviceId); | ||||||
|  | 		} | ||||||
|  | 		config.headers["X-Device-ID"] = deviceId; | ||||||
|  |  | ||||||
| 		return config; | 		return config; | ||||||
| 	}, | 	}, | ||||||
| 	(error) => { | 	(error) => { | ||||||
| @@ -95,6 +119,8 @@ 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`), | ||||||
|  | 	fetchReport: (hostId) => api.post(`/hosts/${hostId}/fetch-report`), | ||||||
| 	updateFriendlyName: (hostId, friendlyName) => | 	updateFriendlyName: (hostId, friendlyName) => | ||||||
| 		api.patch(`/hosts/${hostId}/friendly-name`, { | 		api.patch(`/hosts/${hostId}/friendly-name`, { | ||||||
| 			friendly_name: friendlyName, | 			friendly_name: friendlyName, | ||||||
| @@ -143,6 +169,12 @@ export const settingsAPI = { | |||||||
| 	getServerUrl: () => api.get("/settings/server-url"), | 	getServerUrl: () => api.get("/settings/server-url"), | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | // User Preferences API | ||||||
|  | export const userPreferencesAPI = { | ||||||
|  | 	get: () => api.get("/user/preferences"), | ||||||
|  | 	update: (preferences) => api.patch("/user/preferences", preferences), | ||||||
|  | }; | ||||||
|  |  | ||||||
| // Agent File Management API | // Agent File Management API | ||||||
| export const agentFileAPI = { | export const agentFileAPI = { | ||||||
| 	getInfo: () => api.get("/hosts/agent/info"), | 	getInfo: () => api.get("/hosts/agent/info"), | ||||||
|   | |||||||
							
								
								
									
										171
									
								
								frontend/src/utils/docker.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								frontend/src/utils/docker.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,171 @@ | |||||||
|  | /** | ||||||
|  |  * Docker-related utility functions for the frontend | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Generate a registry link for a Docker image based on its repository and source | ||||||
|  |  * @param {string} repository - The full repository name (e.g., "ghcr.io/owner/repo") | ||||||
|  |  * @param {string} source - The detected source (github, gitlab, docker-hub, etc.) | ||||||
|  |  * @returns {string|null} - The URL to the registry page, or null if unknown | ||||||
|  |  */ | ||||||
|  | export function generateRegistryLink(repository, source) { | ||||||
|  | 	if (!repository) { | ||||||
|  | 		return null; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Parse the domain and path from the repository | ||||||
|  | 	const parts = repository.split("/"); | ||||||
|  | 	let domain = ""; | ||||||
|  | 	let path = ""; | ||||||
|  |  | ||||||
|  | 	// Check if repository has a domain (contains a dot) | ||||||
|  | 	if (parts[0].includes(".") || parts[0].includes(":")) { | ||||||
|  | 		domain = parts[0]; | ||||||
|  | 		path = parts.slice(1).join("/"); | ||||||
|  | 	} else { | ||||||
|  | 		// No domain means Docker Hub | ||||||
|  | 		domain = "docker.io"; | ||||||
|  | 		path = repository; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	switch (source) { | ||||||
|  | 		case "docker-hub": | ||||||
|  | 		case "docker.io": { | ||||||
|  | 			// Docker Hub: https://hub.docker.com/r/{path} or https://hub.docker.com/_/{path} for official images | ||||||
|  | 			// Official images are those without a namespace (e.g., "postgres" not "user/postgres") | ||||||
|  | 			// or explicitly prefixed with "library/" | ||||||
|  | 			if (path.startsWith("library/")) { | ||||||
|  | 				const cleanPath = path.replace("library/", ""); | ||||||
|  | 				return `https://hub.docker.com/_/${cleanPath}`; | ||||||
|  | 			} | ||||||
|  | 			// Check if it's an official image (single part, no slash after removing library/) | ||||||
|  | 			if (!path.includes("/")) { | ||||||
|  | 				return `https://hub.docker.com/_/${path}`; | ||||||
|  | 			} | ||||||
|  | 			// Regular user/org image | ||||||
|  | 			return `https://hub.docker.com/r/${path}`; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		case "github": | ||||||
|  | 		case "ghcr.io": { | ||||||
|  | 			// GitHub Container Registry | ||||||
|  | 			// Format: ghcr.io/{owner}/{package} or ghcr.io/{owner}/{repo}/{package} | ||||||
|  | 			// URL format: https://github.com/{owner}/{repo}/pkgs/container/{package} | ||||||
|  | 			if (domain === "ghcr.io" && path) { | ||||||
|  | 				const pathParts = path.split("/"); | ||||||
|  | 				if (pathParts.length === 2) { | ||||||
|  | 					// Simple case: ghcr.io/owner/package -> github.com/owner/owner/pkgs/container/package | ||||||
|  | 					// OR: ghcr.io/owner/repo -> github.com/owner/repo/pkgs/container/{package} | ||||||
|  | 					// Actually, for 2 parts it's owner/package, and repo is same as owner typically | ||||||
|  | 					const owner = pathParts[0]; | ||||||
|  | 					const packageName = pathParts[1]; | ||||||
|  | 					return `https://github.com/${owner}/${owner}/pkgs/container/${packageName}`; | ||||||
|  | 				} else if (pathParts.length >= 3) { | ||||||
|  | 					// Extended case: ghcr.io/owner/repo/package -> github.com/owner/repo/pkgs/container/package | ||||||
|  | 					const owner = pathParts[0]; | ||||||
|  | 					const repo = pathParts[1]; | ||||||
|  | 					const packageName = pathParts.slice(2).join("/"); | ||||||
|  | 					return `https://github.com/${owner}/${repo}/pkgs/container/${packageName}`; | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 			// Legacy GitHub Packages | ||||||
|  | 			if (domain === "docker.pkg.github.com" && path) { | ||||||
|  | 				const pathParts = path.split("/"); | ||||||
|  | 				if (pathParts.length >= 1) { | ||||||
|  | 					return `https://github.com/${pathParts[0]}/packages`; | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 			return null; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		case "gitlab": | ||||||
|  | 		case "registry.gitlab.com": { | ||||||
|  | 			// GitLab Container Registry | ||||||
|  | 			if (path) { | ||||||
|  | 				return `https://gitlab.com/${path}/container_registry`; | ||||||
|  | 			} | ||||||
|  | 			return null; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		case "google": | ||||||
|  | 		case "gcr.io": { | ||||||
|  | 			// Google Container Registry | ||||||
|  | 			if (domain.includes("gcr.io") || domain.includes("pkg.dev")) { | ||||||
|  | 				return `https://console.cloud.google.com/gcr/images/${path}`; | ||||||
|  | 			} | ||||||
|  | 			return null; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		case "quay": | ||||||
|  | 		case "quay.io": { | ||||||
|  | 			// Quay.io | ||||||
|  | 			if (path) { | ||||||
|  | 				return `https://quay.io/repository/${path}`; | ||||||
|  | 			} | ||||||
|  | 			return null; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		case "redhat": | ||||||
|  | 		case "registry.access.redhat.com": { | ||||||
|  | 			// Red Hat | ||||||
|  | 			if (path) { | ||||||
|  | 				return `https://access.redhat.com/containers/#/registry.access.redhat.com/${path}`; | ||||||
|  | 			} | ||||||
|  | 			return null; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		case "azure": | ||||||
|  | 		case "azurecr.io": { | ||||||
|  | 			// Azure Container Registry | ||||||
|  | 			if (domain.includes("azurecr.io")) { | ||||||
|  | 				const registryName = domain.split(".")[0]; | ||||||
|  | 				return `https://portal.azure.com/#view/Microsoft_Azure_ContainerRegistries/RepositoryBlade/registryName/${registryName}/repositoryName/${path}`; | ||||||
|  | 			} | ||||||
|  | 			return null; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		case "aws": | ||||||
|  | 		case "amazonaws.com": { | ||||||
|  | 			// AWS ECR | ||||||
|  | 			if (domain.includes("amazonaws.com")) { | ||||||
|  | 				const domainParts = domain.split("."); | ||||||
|  | 				const region = domainParts[3]; // Extract region | ||||||
|  | 				return `https://${region}.console.aws.amazon.com/ecr/repositories/private/${path}`; | ||||||
|  | 			} | ||||||
|  | 			return null; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		case "private": | ||||||
|  | 			// For private registries, try to construct a basic URL | ||||||
|  | 			if (domain) { | ||||||
|  | 				return `https://${domain}`; | ||||||
|  | 			} | ||||||
|  | 			return null; | ||||||
|  |  | ||||||
|  | 		default: | ||||||
|  | 			return null; | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Get a user-friendly display name for a registry source | ||||||
|  |  * @param {string} source - The source identifier | ||||||
|  |  * @returns {string} - Human-readable source name | ||||||
|  |  */ | ||||||
|  | export function getSourceDisplayName(source) { | ||||||
|  | 	const sourceNames = { | ||||||
|  | 		"docker-hub": "Docker Hub", | ||||||
|  | 		github: "GitHub", | ||||||
|  | 		gitlab: "GitLab", | ||||||
|  | 		google: "Google", | ||||||
|  | 		quay: "Quay.io", | ||||||
|  | 		redhat: "Red Hat", | ||||||
|  | 		azure: "Azure", | ||||||
|  | 		aws: "AWS ECR", | ||||||
|  | 		private: "Private Registry", | ||||||
|  | 		local: "Local", | ||||||
|  | 		unknown: "Unknown", | ||||||
|  | 	}; | ||||||
|  |  | ||||||
|  | 	return sourceNames[source] || source; | ||||||
|  | } | ||||||
| @@ -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) => { | ||||||
|   | |||||||
							
								
								
									
										1956
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1956
									
								
								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.2", | ||||||
| 	"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