mirror of
				https://github.com/9technologygroup/patchmon.net.git
				synced 2025-11-04 05:53:27 +00:00 
			
		
		
		
	Compare commits
	
		
			62 Commits
		
	
	
		
			post1-3-0
			...
			renovate/m
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					9335f821f7 | ||
| 
						 | 
					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"
 | 
			
		||||
log_file: "/etc/patchmon/logs/patchmon-agent.log"
 | 
			
		||||
log_level: "info"
 | 
			
		||||
skip_ssl_verify: ${SKIP_SSL_VERIFY:-false}
 | 
			
		||||
EOF
 | 
			
		||||
 | 
			
		||||
# 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_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_SECRET=your-secure-random-secret-key-change-this-in-production
 | 
			
		||||
JWT_EXPIRES_IN=1h
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
	"name": "patchmon-backend",
 | 
			
		||||
	"version": "1.3.0",
 | 
			
		||||
	"version": "1.3.1",
 | 
			
		||||
	"description": "Backend API for Linux Patch Monitoring System",
 | 
			
		||||
	"license": "AGPL-3.0",
 | 
			
		||||
	"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;
 | 
			
		||||
 | 
			
		||||
@@ -170,27 +170,31 @@ model role_permissions {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model settings {
 | 
			
		||||
  id                String    @id
 | 
			
		||||
  server_url        String    @default("http://localhost:3001")
 | 
			
		||||
  server_protocol   String    @default("http")
 | 
			
		||||
  server_host       String    @default("localhost")
 | 
			
		||||
  server_port       Int       @default(3001)
 | 
			
		||||
  created_at        DateTime  @default(now())
 | 
			
		||||
  updated_at        DateTime
 | 
			
		||||
  update_interval   Int       @default(60)
 | 
			
		||||
  auto_update       Boolean   @default(false)
 | 
			
		||||
  github_repo_url   String    @default("https://github.com/PatchMon/PatchMon.git")
 | 
			
		||||
  ssh_key_path      String?
 | 
			
		||||
  repository_type   String    @default("public")
 | 
			
		||||
  last_update_check DateTime?
 | 
			
		||||
  latest_version    String?
 | 
			
		||||
  update_available  Boolean   @default(false)
 | 
			
		||||
  signup_enabled    Boolean   @default(false)
 | 
			
		||||
  default_user_role String    @default("user")
 | 
			
		||||
  ignore_ssl_self_signed Boolean @default(false)
 | 
			
		||||
  logo_dark         String?   @default("/assets/logo_dark.png")
 | 
			
		||||
  logo_light        String?   @default("/assets/logo_light.png")
 | 
			
		||||
  favicon           String?   @default("/assets/logo_square.svg")
 | 
			
		||||
  id                     String    @id
 | 
			
		||||
  server_url             String    @default("http://localhost:3001")
 | 
			
		||||
  server_protocol        String    @default("http")
 | 
			
		||||
  server_host            String    @default("localhost")
 | 
			
		||||
  server_port            Int       @default(3001)
 | 
			
		||||
  created_at             DateTime  @default(now())
 | 
			
		||||
  updated_at             DateTime
 | 
			
		||||
  update_interval        Int       @default(60)
 | 
			
		||||
  auto_update            Boolean   @default(false)
 | 
			
		||||
  github_repo_url        String    @default("https://github.com/PatchMon/PatchMon.git")
 | 
			
		||||
  ssh_key_path           String?
 | 
			
		||||
  repository_type        String    @default("public")
 | 
			
		||||
  last_update_check      DateTime?
 | 
			
		||||
  latest_version         String?
 | 
			
		||||
  update_available       Boolean   @default(false)
 | 
			
		||||
  signup_enabled         Boolean   @default(false)
 | 
			
		||||
  default_user_role      String    @default("user")
 | 
			
		||||
  ignore_ssl_self_signed Boolean   @default(false)
 | 
			
		||||
  logo_dark              String?   @default("/assets/logo_dark.png")
 | 
			
		||||
  logo_light             String?   @default("/assets/logo_light.png")
 | 
			
		||||
  favicon                String?   @default("/assets/logo_square.svg")
 | 
			
		||||
  metrics_enabled        Boolean   @default(true)
 | 
			
		||||
  metrics_anonymous_id   String?
 | 
			
		||||
  metrics_last_sent      DateTime?
 | 
			
		||||
  color_theme            String    @default("default")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model update_history {
 | 
			
		||||
 
 | 
			
		||||
@@ -16,12 +16,28 @@ function getOptimizedDatabaseUrl() {
 | 
			
		||||
	// Parse the URL
 | 
			
		||||
	const url = new URL(originalUrl);
 | 
			
		||||
 | 
			
		||||
	// Add connection pooling parameters for multiple instances
 | 
			
		||||
	url.searchParams.set("connection_limit", "5"); // Reduced from default 10
 | 
			
		||||
	url.searchParams.set("pool_timeout", "10"); // 10 seconds
 | 
			
		||||
	url.searchParams.set("connect_timeout", "10"); // 10 seconds
 | 
			
		||||
	url.searchParams.set("idle_timeout", "300"); // 5 minutes
 | 
			
		||||
	url.searchParams.set("max_lifetime", "1800"); // 30 minutes
 | 
			
		||||
	// Add connection pooling parameters - configurable via environment variables
 | 
			
		||||
	const connectionLimit = process.env.DB_CONNECTION_LIMIT || "30";
 | 
			
		||||
	const poolTimeout = process.env.DB_POOL_TIMEOUT || "20";
 | 
			
		||||
	const connectTimeout = process.env.DB_CONNECT_TIMEOUT || "10";
 | 
			
		||||
	const idleTimeout = process.env.DB_IDLE_TIMEOUT || "300";
 | 
			
		||||
	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();
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
router.get("/health", authenticateToken, async (_req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
@@ -274,6 +298,7 @@ router.get("/overview", authenticateToken, async (_req, res) => {
 | 
			
		||||
			queueManager.getRecentJobs(QUEUE_NAMES.SESSION_CLEANUP, 1),
 | 
			
		||||
			queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_REPO_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),
 | 
			
		||||
		]);
 | 
			
		||||
 | 
			
		||||
@@ -283,19 +308,22 @@ router.get("/overview", authenticateToken, async (_req, res) => {
 | 
			
		||||
				stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].delayed +
 | 
			
		||||
				stats[QUEUE_NAMES.SESSION_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:
 | 
			
		||||
				stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].active +
 | 
			
		||||
				stats[QUEUE_NAMES.SESSION_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:
 | 
			
		||||
				stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].failed +
 | 
			
		||||
				stats[QUEUE_NAMES.SESSION_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) => {
 | 
			
		||||
				return (
 | 
			
		||||
@@ -375,10 +403,11 @@ router.get("/overview", authenticateToken, async (_req, res) => {
 | 
			
		||||
					stats: stats[QUEUE_NAMES.ORPHANED_PACKAGE_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)`,
 | 
			
		||||
					name: "Docker Inventory Cleanup",
 | 
			
		||||
					queue: QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP,
 | 
			
		||||
					description:
 | 
			
		||||
						"Removes Docker containers and images for non-existent hosts",
 | 
			
		||||
					schedule: "Daily at 4 AM",
 | 
			
		||||
					lastRun: recentJobs[4][0]?.finishedOn
 | 
			
		||||
						? new Date(recentJobs[4][0].finishedOn).toLocaleString()
 | 
			
		||||
						: "Never",
 | 
			
		||||
@@ -388,6 +417,22 @@ router.get("/overview", authenticateToken, async (_req, res) => {
 | 
			
		||||
						: recentJobs[4][0]
 | 
			
		||||
							? "Success"
 | 
			
		||||
							: "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],
 | 
			
		||||
				},
 | 
			
		||||
			].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) => {
 | 
			
		||||
	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({
 | 
			
		||||
			// Show all hosts regardless of status
 | 
			
		||||
			select: {
 | 
			
		||||
				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" },
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		// Get update counts for each host separately
 | 
			
		||||
		const hostsWithUpdateInfo = await Promise.all(
 | 
			
		||||
			hosts.map(async (host) => {
 | 
			
		||||
				const updatesCount = await prisma.host_packages.count({
 | 
			
		||||
					where: {
 | 
			
		||||
						host_id: host.id,
 | 
			
		||||
						needs_update: true,
 | 
			
		||||
					},
 | 
			
		||||
				});
 | 
			
		||||
		// OPTIMIZATION: Get all package counts in 2 batch queries instead of N*2 queries
 | 
			
		||||
		const hostIds = hosts.map((h) => h.id);
 | 
			
		||||
 | 
			
		||||
				// Get total packages count for this host
 | 
			
		||||
				const totalPackagesCount = await prisma.host_packages.count({
 | 
			
		||||
					where: {
 | 
			
		||||
						host_id: host.id,
 | 
			
		||||
					},
 | 
			
		||||
				});
 | 
			
		||||
 | 
			
		||||
				// Get the agent update interval setting for stale calculation
 | 
			
		||||
				const settings = await prisma.settings.findFirst();
 | 
			
		||||
				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,
 | 
			
		||||
				};
 | 
			
		||||
		const [updateCounts, totalCounts] = await Promise.all([
 | 
			
		||||
			// Get update counts for all hosts at once
 | 
			
		||||
			prisma.host_packages.groupBy({
 | 
			
		||||
				by: ["host_id"],
 | 
			
		||||
				where: {
 | 
			
		||||
					host_id: { in: hostIds },
 | 
			
		||||
					needs_update: true,
 | 
			
		||||
				},
 | 
			
		||||
				_count: { id: true },
 | 
			
		||||
			}),
 | 
			
		||||
			// 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);
 | 
			
		||||
	} 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) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const { apiId, apiKey, containers, images, updates } = req.body;
 | 
			
		||||
@@ -745,6 +746,322 @@ router.post("/collect", async (req, res) => {
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// POST /api/v1/integrations/docker - New integration endpoint for Docker data collection
 | 
			
		||||
router.post("/../integrations/docker", async (req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const apiId = req.headers["x-api-id"];
 | 
			
		||||
		const apiKey = req.headers["x-api-key"];
 | 
			
		||||
		const {
 | 
			
		||||
			containers,
 | 
			
		||||
			images,
 | 
			
		||||
			updates,
 | 
			
		||||
			daemon_info: _daemon_info,
 | 
			
		||||
			hostname,
 | 
			
		||||
			machine_id,
 | 
			
		||||
			agent_version: _agent_version,
 | 
			
		||||
		} = req.body;
 | 
			
		||||
 | 
			
		||||
		console.log(
 | 
			
		||||
			`[Docker Integration] Received data from ${hostname || machine_id}`,
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		// Validate API credentials
 | 
			
		||||
		const host = await prisma.hosts.findFirst({
 | 
			
		||||
			where: { api_id: apiId, api_key: apiKey },
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		if (!host) {
 | 
			
		||||
			console.warn("[Docker Integration] Invalid API credentials");
 | 
			
		||||
			return res.status(401).json({ error: "Invalid API credentials" });
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		console.log(
 | 
			
		||||
			`[Docker Integration] Processing for host: ${host.friendly_name}`,
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		const now = new Date();
 | 
			
		||||
 | 
			
		||||
		// Helper function to validate and parse dates
 | 
			
		||||
		const parseDate = (dateString) => {
 | 
			
		||||
			if (!dateString) return now;
 | 
			
		||||
			const date = new Date(dateString);
 | 
			
		||||
			return Number.isNaN(date.getTime()) ? now : date;
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		let containersProcessed = 0;
 | 
			
		||||
		let imagesProcessed = 0;
 | 
			
		||||
		let updatesProcessed = 0;
 | 
			
		||||
 | 
			
		||||
		// Process containers
 | 
			
		||||
		if (containers && Array.isArray(containers)) {
 | 
			
		||||
			console.log(
 | 
			
		||||
				`[Docker Integration] Processing ${containers.length} containers`,
 | 
			
		||||
			);
 | 
			
		||||
			for (const containerData of containers) {
 | 
			
		||||
				const containerId = uuidv4();
 | 
			
		||||
 | 
			
		||||
				// Find or create image
 | 
			
		||||
				let imageId = null;
 | 
			
		||||
				if (containerData.image_repository && containerData.image_tag) {
 | 
			
		||||
					const image = await prisma.docker_images.upsert({
 | 
			
		||||
						where: {
 | 
			
		||||
							repository_tag_image_id: {
 | 
			
		||||
								repository: containerData.image_repository,
 | 
			
		||||
								tag: containerData.image_tag,
 | 
			
		||||
								image_id: containerData.image_id || "unknown",
 | 
			
		||||
							},
 | 
			
		||||
						},
 | 
			
		||||
						update: {
 | 
			
		||||
							last_checked: now,
 | 
			
		||||
							updated_at: now,
 | 
			
		||||
						},
 | 
			
		||||
						create: {
 | 
			
		||||
							id: uuidv4(),
 | 
			
		||||
							repository: containerData.image_repository,
 | 
			
		||||
							tag: containerData.image_tag,
 | 
			
		||||
							image_id: containerData.image_id || "unknown",
 | 
			
		||||
							source: containerData.image_source || "docker-hub",
 | 
			
		||||
							created_at: parseDate(containerData.created_at),
 | 
			
		||||
							updated_at: now,
 | 
			
		||||
						},
 | 
			
		||||
					});
 | 
			
		||||
					imageId = image.id;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				// Upsert container
 | 
			
		||||
				await prisma.docker_containers.upsert({
 | 
			
		||||
					where: {
 | 
			
		||||
						host_id_container_id: {
 | 
			
		||||
							host_id: host.id,
 | 
			
		||||
							container_id: containerData.container_id,
 | 
			
		||||
						},
 | 
			
		||||
					},
 | 
			
		||||
					update: {
 | 
			
		||||
						name: containerData.name,
 | 
			
		||||
						image_id: imageId,
 | 
			
		||||
						image_name: containerData.image_name,
 | 
			
		||||
						image_tag: containerData.image_tag || "latest",
 | 
			
		||||
						status: containerData.status,
 | 
			
		||||
						state: containerData.state || containerData.status,
 | 
			
		||||
						ports: containerData.ports || null,
 | 
			
		||||
						started_at: containerData.started_at
 | 
			
		||||
							? parseDate(containerData.started_at)
 | 
			
		||||
							: null,
 | 
			
		||||
						updated_at: now,
 | 
			
		||||
						last_checked: now,
 | 
			
		||||
					},
 | 
			
		||||
					create: {
 | 
			
		||||
						id: containerId,
 | 
			
		||||
						host_id: host.id,
 | 
			
		||||
						container_id: containerData.container_id,
 | 
			
		||||
						name: containerData.name,
 | 
			
		||||
						image_id: imageId,
 | 
			
		||||
						image_name: containerData.image_name,
 | 
			
		||||
						image_tag: containerData.image_tag || "latest",
 | 
			
		||||
						status: containerData.status,
 | 
			
		||||
						state: containerData.state || containerData.status,
 | 
			
		||||
						ports: containerData.ports || null,
 | 
			
		||||
						created_at: parseDate(containerData.created_at),
 | 
			
		||||
						started_at: containerData.started_at
 | 
			
		||||
							? parseDate(containerData.started_at)
 | 
			
		||||
							: null,
 | 
			
		||||
						updated_at: now,
 | 
			
		||||
					},
 | 
			
		||||
				});
 | 
			
		||||
				containersProcessed++;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Process standalone images
 | 
			
		||||
		if (images && Array.isArray(images)) {
 | 
			
		||||
			console.log(`[Docker Integration] Processing ${images.length} images`);
 | 
			
		||||
			for (const imageData of images) {
 | 
			
		||||
				await prisma.docker_images.upsert({
 | 
			
		||||
					where: {
 | 
			
		||||
						repository_tag_image_id: {
 | 
			
		||||
							repository: imageData.repository,
 | 
			
		||||
							tag: imageData.tag,
 | 
			
		||||
							image_id: imageData.image_id,
 | 
			
		||||
						},
 | 
			
		||||
					},
 | 
			
		||||
					update: {
 | 
			
		||||
						size_bytes: imageData.size_bytes
 | 
			
		||||
							? BigInt(imageData.size_bytes)
 | 
			
		||||
							: null,
 | 
			
		||||
						digest: imageData.digest || null,
 | 
			
		||||
						last_checked: now,
 | 
			
		||||
						updated_at: now,
 | 
			
		||||
					},
 | 
			
		||||
					create: {
 | 
			
		||||
						id: uuidv4(),
 | 
			
		||||
						repository: imageData.repository,
 | 
			
		||||
						tag: imageData.tag,
 | 
			
		||||
						image_id: imageData.image_id,
 | 
			
		||||
						digest: imageData.digest,
 | 
			
		||||
						size_bytes: imageData.size_bytes
 | 
			
		||||
							? BigInt(imageData.size_bytes)
 | 
			
		||||
							: null,
 | 
			
		||||
						source: imageData.source || "docker-hub",
 | 
			
		||||
						created_at: parseDate(imageData.created_at),
 | 
			
		||||
						updated_at: now,
 | 
			
		||||
					},
 | 
			
		||||
				});
 | 
			
		||||
				imagesProcessed++;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Process updates
 | 
			
		||||
		if (updates && Array.isArray(updates)) {
 | 
			
		||||
			console.log(`[Docker Integration] Processing ${updates.length} updates`);
 | 
			
		||||
			for (const updateData of updates) {
 | 
			
		||||
				// Find the image by repository and image_id
 | 
			
		||||
				const image = await prisma.docker_images.findFirst({
 | 
			
		||||
					where: {
 | 
			
		||||
						repository: updateData.repository,
 | 
			
		||||
						tag: updateData.current_tag,
 | 
			
		||||
						image_id: updateData.image_id,
 | 
			
		||||
					},
 | 
			
		||||
				});
 | 
			
		||||
 | 
			
		||||
				if (image) {
 | 
			
		||||
					// Store digest info in changelog_url field as JSON
 | 
			
		||||
					const digestInfo = JSON.stringify({
 | 
			
		||||
						method: "digest_comparison",
 | 
			
		||||
						current_digest: updateData.current_digest,
 | 
			
		||||
						available_digest: updateData.available_digest,
 | 
			
		||||
					});
 | 
			
		||||
 | 
			
		||||
					// Upsert the update record
 | 
			
		||||
					await prisma.docker_image_updates.upsert({
 | 
			
		||||
						where: {
 | 
			
		||||
							image_id_available_tag: {
 | 
			
		||||
								image_id: image.id,
 | 
			
		||||
								available_tag: updateData.available_tag,
 | 
			
		||||
							},
 | 
			
		||||
						},
 | 
			
		||||
						update: {
 | 
			
		||||
							updated_at: now,
 | 
			
		||||
							changelog_url: digestInfo,
 | 
			
		||||
							severity: "digest_changed",
 | 
			
		||||
						},
 | 
			
		||||
						create: {
 | 
			
		||||
							id: uuidv4(),
 | 
			
		||||
							image_id: image.id,
 | 
			
		||||
							current_tag: updateData.current_tag,
 | 
			
		||||
							available_tag: updateData.available_tag,
 | 
			
		||||
							severity: "digest_changed",
 | 
			
		||||
							changelog_url: digestInfo,
 | 
			
		||||
							updated_at: now,
 | 
			
		||||
						},
 | 
			
		||||
					});
 | 
			
		||||
					updatesProcessed++;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		console.log(
 | 
			
		||||
			`[Docker Integration] Successfully processed: ${containersProcessed} containers, ${imagesProcessed} images, ${updatesProcessed} updates`,
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		res.json({
 | 
			
		||||
			message: "Docker data collected successfully",
 | 
			
		||||
			containers_received: containersProcessed,
 | 
			
		||||
			images_received: imagesProcessed,
 | 
			
		||||
			updates_found: updatesProcessed,
 | 
			
		||||
		});
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error("[Docker Integration] Error collecting Docker data:", error);
 | 
			
		||||
		console.error("[Docker Integration] Error stack:", error.stack);
 | 
			
		||||
		res.status(500).json({
 | 
			
		||||
			error: "Failed to collect Docker data",
 | 
			
		||||
			message: error.message,
 | 
			
		||||
			details: process.env.NODE_ENV === "development" ? error.stack : undefined,
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// DELETE /api/v1/docker/containers/:id - Delete a container
 | 
			
		||||
router.delete("/containers/:id", authenticateToken, async (req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const { id } = req.params;
 | 
			
		||||
 | 
			
		||||
		// Check if container exists
 | 
			
		||||
		const container = await prisma.docker_containers.findUnique({
 | 
			
		||||
			where: { id },
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		if (!container) {
 | 
			
		||||
			return res.status(404).json({ error: "Container not found" });
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Delete the container
 | 
			
		||||
		await prisma.docker_containers.delete({
 | 
			
		||||
			where: { id },
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		console.log(`🗑️  Deleted container: ${container.name} (${id})`);
 | 
			
		||||
 | 
			
		||||
		res.json({
 | 
			
		||||
			success: true,
 | 
			
		||||
			message: `Container ${container.name} deleted successfully`,
 | 
			
		||||
		});
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error("Error deleting container:", error);
 | 
			
		||||
		res.status(500).json({ error: "Failed to delete container" });
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// DELETE /api/v1/docker/images/:id - Delete an image
 | 
			
		||||
router.delete("/images/:id", authenticateToken, async (req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const { id } = req.params;
 | 
			
		||||
 | 
			
		||||
		// Check if image exists
 | 
			
		||||
		const image = await prisma.docker_images.findUnique({
 | 
			
		||||
			where: { id },
 | 
			
		||||
			include: {
 | 
			
		||||
				_count: {
 | 
			
		||||
					select: {
 | 
			
		||||
						docker_containers: true,
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		if (!image) {
 | 
			
		||||
			return res.status(404).json({ error: "Image not found" });
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Check if image is in use by containers
 | 
			
		||||
		if (image._count.docker_containers > 0) {
 | 
			
		||||
			return res.status(400).json({
 | 
			
		||||
				error: `Cannot delete image: ${image._count.docker_containers} container(s) are using this image`,
 | 
			
		||||
				containersCount: image._count.docker_containers,
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Delete image updates first
 | 
			
		||||
		await prisma.docker_image_updates.deleteMany({
 | 
			
		||||
			where: { image_id: id },
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		// Delete the image
 | 
			
		||||
		await prisma.docker_images.delete({
 | 
			
		||||
			where: { id },
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		console.log(`🗑️  Deleted image: ${image.repository}:${image.tag} (${id})`);
 | 
			
		||||
 | 
			
		||||
		res.json({
 | 
			
		||||
			success: true,
 | 
			
		||||
			message: `Image ${image.repository}:${image.tag} deleted successfully`,
 | 
			
		||||
		});
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error("Error deleting image:", error);
 | 
			
		||||
		res.status(500).json({ error: "Failed to delete image" });
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// GET /api/v1/docker/agent - Serve the Docker agent installation script
 | 
			
		||||
router.get("/agent", async (_req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
 
 | 
			
		||||
@@ -356,6 +356,26 @@ router.post(
 | 
			
		||||
			});
 | 
			
		||||
		} catch (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" });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
@@ -786,19 +806,41 @@ router.get("/info", validateApiCredentials, async (req, res) => {
 | 
			
		||||
// Ping endpoint for health checks (now uses API credentials)
 | 
			
		||||
router.post("/ping", validateApiCredentials, async (req, res) => {
 | 
			
		||||
	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({
 | 
			
		||||
			where: { id: req.hostRecord.id },
 | 
			
		||||
			data: {
 | 
			
		||||
				last_update: new Date(),
 | 
			
		||||
				updated_at: new Date(),
 | 
			
		||||
				last_update: now,
 | 
			
		||||
				updated_at: now,
 | 
			
		||||
				status: "active",
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		const response = {
 | 
			
		||||
			message: "Ping successful",
 | 
			
		||||
			timestamp: new Date().toISOString(),
 | 
			
		||||
			timestamp: now.toISOString(),
 | 
			
		||||
			friendlyName: req.hostRecord.friendly_name,
 | 
			
		||||
			agentStartup: isStartup,
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		// Check if this is a crontab update trigger
 | 
			
		||||
@@ -1441,10 +1483,12 @@ router.get("/install", async (req, res) => {
 | 
			
		||||
 | 
			
		||||
		// Determine curl flags dynamically from settings (ignore self-signed)
 | 
			
		||||
		let curlFlags = "-s";
 | 
			
		||||
		let skipSSLVerify = "false";
 | 
			
		||||
		try {
 | 
			
		||||
			const settings = await prisma.settings.findFirst();
 | 
			
		||||
			if (settings && settings.ignore_ssl_self_signed === true) {
 | 
			
		||||
				curlFlags = "-sk";
 | 
			
		||||
				skipSSLVerify = "true";
 | 
			
		||||
			}
 | 
			
		||||
		} catch (_) {}
 | 
			
		||||
 | 
			
		||||
@@ -1454,12 +1498,13 @@ router.get("/install", async (req, res) => {
 | 
			
		||||
		// Get architecture parameter (default to 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
 | 
			
		||||
export PATCHMON_URL="${serverUrl}"
 | 
			
		||||
export API_ID="${host.api_id}"
 | 
			
		||||
export API_KEY="${host.api_key}"
 | 
			
		||||
export CURL_FLAGS="${curlFlags}"
 | 
			
		||||
export SKIP_SSL_VERIFY="${skipSSLVerify}"
 | 
			
		||||
export FORCE_INSTALL="${forceInstall ? "true" : "false"}"
 | 
			
		||||
export ARCHITECTURE="${architecture}"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										242
									
								
								backend/src/routes/integrationRoutes.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										242
									
								
								backend/src/routes/integrationRoutes.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,242 @@
 | 
			
		||||
const express = require("express");
 | 
			
		||||
const { getPrismaClient } = require("../config/prisma");
 | 
			
		||||
const { v4: uuidv4 } = require("uuid");
 | 
			
		||||
 | 
			
		||||
const prisma = getPrismaClient();
 | 
			
		||||
const router = express.Router();
 | 
			
		||||
 | 
			
		||||
// POST /api/v1/integrations/docker - Docker data collection endpoint
 | 
			
		||||
router.post("/docker", async (req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const apiId = req.headers["x-api-id"];
 | 
			
		||||
		const apiKey = req.headers["x-api-key"];
 | 
			
		||||
		const {
 | 
			
		||||
			containers,
 | 
			
		||||
			images,
 | 
			
		||||
			updates,
 | 
			
		||||
			daemon_info: _daemon_info,
 | 
			
		||||
			hostname,
 | 
			
		||||
			machine_id,
 | 
			
		||||
			agent_version: _agent_version,
 | 
			
		||||
		} = req.body;
 | 
			
		||||
 | 
			
		||||
		console.log(
 | 
			
		||||
			`[Docker Integration] Received data from ${hostname || machine_id}`,
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		// Validate API credentials
 | 
			
		||||
		const host = await prisma.hosts.findFirst({
 | 
			
		||||
			where: { api_id: apiId, api_key: apiKey },
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		if (!host) {
 | 
			
		||||
			console.warn("[Docker Integration] Invalid API credentials");
 | 
			
		||||
			return res.status(401).json({ error: "Invalid API credentials" });
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		console.log(
 | 
			
		||||
			`[Docker Integration] Processing for host: ${host.friendly_name}`,
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		const now = new Date();
 | 
			
		||||
 | 
			
		||||
		// Helper function to validate and parse dates
 | 
			
		||||
		const parseDate = (dateString) => {
 | 
			
		||||
			if (!dateString) return now;
 | 
			
		||||
			const date = new Date(dateString);
 | 
			
		||||
			return Number.isNaN(date.getTime()) ? now : date;
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		let containersProcessed = 0;
 | 
			
		||||
		let imagesProcessed = 0;
 | 
			
		||||
		let updatesProcessed = 0;
 | 
			
		||||
 | 
			
		||||
		// Process containers
 | 
			
		||||
		if (containers && Array.isArray(containers)) {
 | 
			
		||||
			console.log(
 | 
			
		||||
				`[Docker Integration] Processing ${containers.length} containers`,
 | 
			
		||||
			);
 | 
			
		||||
			for (const containerData of containers) {
 | 
			
		||||
				const containerId = uuidv4();
 | 
			
		||||
 | 
			
		||||
				// Find or create image
 | 
			
		||||
				let imageId = null;
 | 
			
		||||
				if (containerData.image_repository && containerData.image_tag) {
 | 
			
		||||
					const image = await prisma.docker_images.upsert({
 | 
			
		||||
						where: {
 | 
			
		||||
							repository_tag_image_id: {
 | 
			
		||||
								repository: containerData.image_repository,
 | 
			
		||||
								tag: containerData.image_tag,
 | 
			
		||||
								image_id: containerData.image_id || "unknown",
 | 
			
		||||
							},
 | 
			
		||||
						},
 | 
			
		||||
						update: {
 | 
			
		||||
							last_checked: now,
 | 
			
		||||
							updated_at: now,
 | 
			
		||||
						},
 | 
			
		||||
						create: {
 | 
			
		||||
							id: uuidv4(),
 | 
			
		||||
							repository: containerData.image_repository,
 | 
			
		||||
							tag: containerData.image_tag,
 | 
			
		||||
							image_id: containerData.image_id || "unknown",
 | 
			
		||||
							source: containerData.image_source || "docker-hub",
 | 
			
		||||
							created_at: parseDate(containerData.created_at),
 | 
			
		||||
							updated_at: now,
 | 
			
		||||
						},
 | 
			
		||||
					});
 | 
			
		||||
					imageId = image.id;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				// Upsert container
 | 
			
		||||
				await prisma.docker_containers.upsert({
 | 
			
		||||
					where: {
 | 
			
		||||
						host_id_container_id: {
 | 
			
		||||
							host_id: host.id,
 | 
			
		||||
							container_id: containerData.container_id,
 | 
			
		||||
						},
 | 
			
		||||
					},
 | 
			
		||||
					update: {
 | 
			
		||||
						name: containerData.name,
 | 
			
		||||
						image_id: imageId,
 | 
			
		||||
						image_name: containerData.image_name,
 | 
			
		||||
						image_tag: containerData.image_tag || "latest",
 | 
			
		||||
						status: containerData.status,
 | 
			
		||||
						state: containerData.state || containerData.status,
 | 
			
		||||
						ports: containerData.ports || null,
 | 
			
		||||
						started_at: containerData.started_at
 | 
			
		||||
							? parseDate(containerData.started_at)
 | 
			
		||||
							: null,
 | 
			
		||||
						updated_at: now,
 | 
			
		||||
						last_checked: now,
 | 
			
		||||
					},
 | 
			
		||||
					create: {
 | 
			
		||||
						id: containerId,
 | 
			
		||||
						host_id: host.id,
 | 
			
		||||
						container_id: containerData.container_id,
 | 
			
		||||
						name: containerData.name,
 | 
			
		||||
						image_id: imageId,
 | 
			
		||||
						image_name: containerData.image_name,
 | 
			
		||||
						image_tag: containerData.image_tag || "latest",
 | 
			
		||||
						status: containerData.status,
 | 
			
		||||
						state: containerData.state || containerData.status,
 | 
			
		||||
						ports: containerData.ports || null,
 | 
			
		||||
						created_at: parseDate(containerData.created_at),
 | 
			
		||||
						started_at: containerData.started_at
 | 
			
		||||
							? parseDate(containerData.started_at)
 | 
			
		||||
							: null,
 | 
			
		||||
						updated_at: now,
 | 
			
		||||
					},
 | 
			
		||||
				});
 | 
			
		||||
				containersProcessed++;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Process standalone images
 | 
			
		||||
		if (images && Array.isArray(images)) {
 | 
			
		||||
			console.log(`[Docker Integration] Processing ${images.length} images`);
 | 
			
		||||
			for (const imageData of images) {
 | 
			
		||||
				await prisma.docker_images.upsert({
 | 
			
		||||
					where: {
 | 
			
		||||
						repository_tag_image_id: {
 | 
			
		||||
							repository: imageData.repository,
 | 
			
		||||
							tag: imageData.tag,
 | 
			
		||||
							image_id: imageData.image_id,
 | 
			
		||||
						},
 | 
			
		||||
					},
 | 
			
		||||
					update: {
 | 
			
		||||
						size_bytes: imageData.size_bytes
 | 
			
		||||
							? BigInt(imageData.size_bytes)
 | 
			
		||||
							: null,
 | 
			
		||||
						digest: imageData.digest || null,
 | 
			
		||||
						last_checked: now,
 | 
			
		||||
						updated_at: now,
 | 
			
		||||
					},
 | 
			
		||||
					create: {
 | 
			
		||||
						id: uuidv4(),
 | 
			
		||||
						repository: imageData.repository,
 | 
			
		||||
						tag: imageData.tag,
 | 
			
		||||
						image_id: imageData.image_id,
 | 
			
		||||
						digest: imageData.digest,
 | 
			
		||||
						size_bytes: imageData.size_bytes
 | 
			
		||||
							? BigInt(imageData.size_bytes)
 | 
			
		||||
							: null,
 | 
			
		||||
						source: imageData.source || "docker-hub",
 | 
			
		||||
						created_at: parseDate(imageData.created_at),
 | 
			
		||||
						updated_at: now,
 | 
			
		||||
					},
 | 
			
		||||
				});
 | 
			
		||||
				imagesProcessed++;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Process updates
 | 
			
		||||
		if (updates && Array.isArray(updates)) {
 | 
			
		||||
			console.log(`[Docker Integration] Processing ${updates.length} updates`);
 | 
			
		||||
			for (const updateData of updates) {
 | 
			
		||||
				// Find the image by repository and image_id
 | 
			
		||||
				const image = await prisma.docker_images.findFirst({
 | 
			
		||||
					where: {
 | 
			
		||||
						repository: updateData.repository,
 | 
			
		||||
						tag: updateData.current_tag,
 | 
			
		||||
						image_id: updateData.image_id,
 | 
			
		||||
					},
 | 
			
		||||
				});
 | 
			
		||||
 | 
			
		||||
				if (image) {
 | 
			
		||||
					// Store digest info in changelog_url field as JSON
 | 
			
		||||
					const digestInfo = JSON.stringify({
 | 
			
		||||
						method: "digest_comparison",
 | 
			
		||||
						current_digest: updateData.current_digest,
 | 
			
		||||
						available_digest: updateData.available_digest,
 | 
			
		||||
					});
 | 
			
		||||
 | 
			
		||||
					// Upsert the update record
 | 
			
		||||
					await prisma.docker_image_updates.upsert({
 | 
			
		||||
						where: {
 | 
			
		||||
							image_id_available_tag: {
 | 
			
		||||
								image_id: image.id,
 | 
			
		||||
								available_tag: updateData.available_tag,
 | 
			
		||||
							},
 | 
			
		||||
						},
 | 
			
		||||
						update: {
 | 
			
		||||
							updated_at: now,
 | 
			
		||||
							changelog_url: digestInfo,
 | 
			
		||||
							severity: "digest_changed",
 | 
			
		||||
						},
 | 
			
		||||
						create: {
 | 
			
		||||
							id: uuidv4(),
 | 
			
		||||
							image_id: image.id,
 | 
			
		||||
							current_tag: updateData.current_tag,
 | 
			
		||||
							available_tag: updateData.available_tag,
 | 
			
		||||
							severity: "digest_changed",
 | 
			
		||||
							changelog_url: digestInfo,
 | 
			
		||||
							updated_at: now,
 | 
			
		||||
						},
 | 
			
		||||
					});
 | 
			
		||||
					updatesProcessed++;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		console.log(
 | 
			
		||||
			`[Docker Integration] Successfully processed: ${containersProcessed} containers, ${imagesProcessed} images, ${updatesProcessed} updates`,
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		res.json({
 | 
			
		||||
			message: "Docker data collected successfully",
 | 
			
		||||
			containers_received: containersProcessed,
 | 
			
		||||
			images_received: imagesProcessed,
 | 
			
		||||
			updates_found: updatesProcessed,
 | 
			
		||||
		});
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error("[Docker Integration] Error collecting Docker data:", error);
 | 
			
		||||
		console.error("[Docker Integration] Error stack:", error.stack);
 | 
			
		||||
		res.status(500).json({
 | 
			
		||||
			error: "Failed to collect Docker data",
 | 
			
		||||
			message: error.message,
 | 
			
		||||
			details: process.env.NODE_ENV === "development" ? error.stack : undefined,
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
module.exports = router;
 | 
			
		||||
							
								
								
									
										148
									
								
								backend/src/routes/metricsRoutes.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										148
									
								
								backend/src/routes/metricsRoutes.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,148 @@
 | 
			
		||||
const express = require("express");
 | 
			
		||||
const { body, validationResult } = require("express-validator");
 | 
			
		||||
const { v4: uuidv4 } = require("uuid");
 | 
			
		||||
const { authenticateToken } = require("../middleware/auth");
 | 
			
		||||
const { requireManageSettings } = require("../middleware/permissions");
 | 
			
		||||
const { getSettings, updateSettings } = require("../services/settingsService");
 | 
			
		||||
const { queueManager, QUEUE_NAMES } = require("../services/automation");
 | 
			
		||||
 | 
			
		||||
const router = express.Router();
 | 
			
		||||
 | 
			
		||||
// Get metrics settings
 | 
			
		||||
router.get("/", authenticateToken, requireManageSettings, async (_req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const settings = await getSettings();
 | 
			
		||||
 | 
			
		||||
		// Generate anonymous ID if it doesn't exist
 | 
			
		||||
		if (!settings.metrics_anonymous_id) {
 | 
			
		||||
			const anonymousId = uuidv4();
 | 
			
		||||
			await updateSettings(settings.id, {
 | 
			
		||||
				metrics_anonymous_id: anonymousId,
 | 
			
		||||
			});
 | 
			
		||||
			settings.metrics_anonymous_id = anonymousId;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		res.json({
 | 
			
		||||
			metrics_enabled: settings.metrics_enabled ?? true,
 | 
			
		||||
			metrics_anonymous_id: settings.metrics_anonymous_id,
 | 
			
		||||
			metrics_last_sent: settings.metrics_last_sent,
 | 
			
		||||
		});
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error("Metrics settings fetch error:", error);
 | 
			
		||||
		res.status(500).json({ error: "Failed to fetch metrics settings" });
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Update metrics settings
 | 
			
		||||
router.put(
 | 
			
		||||
	"/",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireManageSettings,
 | 
			
		||||
	[
 | 
			
		||||
		body("metrics_enabled")
 | 
			
		||||
			.isBoolean()
 | 
			
		||||
			.withMessage("Metrics enabled must be a boolean"),
 | 
			
		||||
	],
 | 
			
		||||
	async (req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
			const errors = validationResult(req);
 | 
			
		||||
			if (!errors.isEmpty()) {
 | 
			
		||||
				return res.status(400).json({ errors: errors.array() });
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			const { metrics_enabled } = req.body;
 | 
			
		||||
			const settings = await getSettings();
 | 
			
		||||
 | 
			
		||||
			await updateSettings(settings.id, {
 | 
			
		||||
				metrics_enabled,
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			console.log(
 | 
			
		||||
				`Metrics ${metrics_enabled ? "enabled" : "disabled"} by user`,
 | 
			
		||||
			);
 | 
			
		||||
 | 
			
		||||
			res.json({
 | 
			
		||||
				message: "Metrics settings updated successfully",
 | 
			
		||||
				metrics_enabled,
 | 
			
		||||
			});
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error("Metrics settings update error:", error);
 | 
			
		||||
			res.status(500).json({ error: "Failed to update metrics settings" });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Regenerate anonymous ID
 | 
			
		||||
router.post(
 | 
			
		||||
	"/regenerate-id",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireManageSettings,
 | 
			
		||||
	async (_req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
			const settings = await getSettings();
 | 
			
		||||
			const newAnonymousId = uuidv4();
 | 
			
		||||
 | 
			
		||||
			await updateSettings(settings.id, {
 | 
			
		||||
				metrics_anonymous_id: newAnonymousId,
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			console.log("Anonymous ID regenerated");
 | 
			
		||||
 | 
			
		||||
			res.json({
 | 
			
		||||
				message: "Anonymous ID regenerated successfully",
 | 
			
		||||
				metrics_anonymous_id: newAnonymousId,
 | 
			
		||||
			});
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error("Anonymous ID regeneration error:", error);
 | 
			
		||||
			res.status(500).json({ error: "Failed to regenerate anonymous ID" });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Manually send metrics now
 | 
			
		||||
router.post(
 | 
			
		||||
	"/send-now",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireManageSettings,
 | 
			
		||||
	async (_req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
			const settings = await getSettings();
 | 
			
		||||
 | 
			
		||||
			if (!settings.metrics_enabled) {
 | 
			
		||||
				return res.status(400).json({
 | 
			
		||||
					error: "Metrics are disabled. Please enable metrics first.",
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Trigger metrics directly (no queue delay for manual trigger)
 | 
			
		||||
			const metricsReporting =
 | 
			
		||||
				queueManager.automations[QUEUE_NAMES.METRICS_REPORTING];
 | 
			
		||||
			const result = await metricsReporting.process(
 | 
			
		||||
				{ name: "manual-send" },
 | 
			
		||||
				false,
 | 
			
		||||
			);
 | 
			
		||||
 | 
			
		||||
			if (result.success) {
 | 
			
		||||
				console.log("✅ Manual metrics sent successfully");
 | 
			
		||||
				res.json({
 | 
			
		||||
					message: "Metrics sent successfully",
 | 
			
		||||
					data: result,
 | 
			
		||||
				});
 | 
			
		||||
			} else {
 | 
			
		||||
				console.error("❌ Failed to send metrics:", result);
 | 
			
		||||
				res.status(500).json({
 | 
			
		||||
					error: "Failed to send metrics",
 | 
			
		||||
					details: result.reason || result.error,
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error("Send metrics error:", error);
 | 
			
		||||
			res.status(500).json({
 | 
			
		||||
				error: "Failed to send metrics",
 | 
			
		||||
				details: error.message,
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
module.exports = router;
 | 
			
		||||
@@ -101,74 +101,107 @@ router.get("/", async (req, res) => {
 | 
			
		||||
			prisma.packages.count({ where }),
 | 
			
		||||
		]);
 | 
			
		||||
 | 
			
		||||
		// Get additional stats for each package
 | 
			
		||||
		const packagesWithStats = await Promise.all(
 | 
			
		||||
			packages.map(async (pkg) => {
 | 
			
		||||
				// Build base where clause for this package
 | 
			
		||||
				const baseWhere = { package_id: pkg.id };
 | 
			
		||||
		// OPTIMIZATION: Batch query all stats instead of N individual queries
 | 
			
		||||
		const packageIds = packages.map((pkg) => pkg.id);
 | 
			
		||||
 | 
			
		||||
				// If host filter is specified, add host filter to all queries
 | 
			
		||||
				const hostWhere = host ? { ...baseWhere, host_id: host } : baseWhere;
 | 
			
		||||
 | 
			
		||||
				const [updatesCount, securityCount, packageHosts] = await Promise.all([
 | 
			
		||||
					prisma.host_packages.count({
 | 
			
		||||
						where: {
 | 
			
		||||
							...hostWhere,
 | 
			
		||||
							needs_update: true,
 | 
			
		||||
						},
 | 
			
		||||
					}),
 | 
			
		||||
					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,
 | 
			
		||||
		// Get all counts and host data in 3 batch queries instead of N*3 queries
 | 
			
		||||
		const [allUpdatesCounts, allSecurityCounts, allPackageHostsData] =
 | 
			
		||||
			await Promise.all([
 | 
			
		||||
				// Batch count all packages that need updates
 | 
			
		||||
				prisma.host_packages.groupBy({
 | 
			
		||||
					by: ["package_id"],
 | 
			
		||||
					where: {
 | 
			
		||||
						package_id: { in: packageIds },
 | 
			
		||||
						needs_update: true,
 | 
			
		||||
						...(host ? { host_id: host } : {}),
 | 
			
		||||
					},
 | 
			
		||||
				};
 | 
			
		||||
			}),
 | 
			
		||||
					_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({
 | 
			
		||||
			packages: packagesWithStats,
 | 
			
		||||
 
 | 
			
		||||
@@ -158,6 +158,7 @@ router.put(
 | 
			
		||||
				logoDark,
 | 
			
		||||
				logoLight,
 | 
			
		||||
				favicon,
 | 
			
		||||
				colorTheme,
 | 
			
		||||
			} = req.body;
 | 
			
		||||
 | 
			
		||||
			// Get current settings to check for update interval changes
 | 
			
		||||
@@ -189,6 +190,7 @@ router.put(
 | 
			
		||||
			if (logoDark !== undefined) updateData.logo_dark = logoDark;
 | 
			
		||||
			if (logoLight !== undefined) updateData.logo_light = logoLight;
 | 
			
		||||
			if (favicon !== undefined) updateData.favicon = favicon;
 | 
			
		||||
			if (colorTheme !== undefined) updateData.color_theme = colorTheme;
 | 
			
		||||
 | 
			
		||||
			const updatedSettings = await updateSettings(
 | 
			
		||||
				currentSettings.id,
 | 
			
		||||
 
 | 
			
		||||
@@ -14,13 +14,16 @@ const router = express.Router();
 | 
			
		||||
function getCurrentVersion() {
 | 
			
		||||
	try {
 | 
			
		||||
		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) {
 | 
			
		||||
		console.warn(
 | 
			
		||||
			"Could not read version from package.json, using fallback:",
 | 
			
		||||
		console.error(
 | 
			
		||||
			"Could not read version from package.json:",
 | 
			
		||||
			packageError.message,
 | 
			
		||||
		);
 | 
			
		||||
		return "1.3.0";
 | 
			
		||||
		return "unknown";
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -11,7 +11,31 @@ const {
 | 
			
		||||
 | 
			
		||||
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) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const { apiId } = req.params;
 | 
			
		||||
 
 | 
			
		||||
@@ -66,8 +66,10 @@ const autoEnrollmentRoutes = require("./routes/autoEnrollmentRoutes");
 | 
			
		||||
const gethomepageRoutes = require("./routes/gethomepageRoutes");
 | 
			
		||||
const automationRoutes = require("./routes/automationRoutes");
 | 
			
		||||
const dockerRoutes = require("./routes/dockerRoutes");
 | 
			
		||||
const integrationRoutes = require("./routes/integrationRoutes");
 | 
			
		||||
const wsRoutes = require("./routes/wsRoutes");
 | 
			
		||||
const agentVersionRoutes = require("./routes/agentVersionRoutes");
 | 
			
		||||
const metricsRoutes = require("./routes/metricsRoutes");
 | 
			
		||||
const { initSettings } = require("./services/settingsService");
 | 
			
		||||
const { queueManager } = require("./services/automation");
 | 
			
		||||
const { authenticateToken, requireAdmin } = require("./middleware/auth");
 | 
			
		||||
@@ -471,8 +473,10 @@ app.use(
 | 
			
		||||
app.use(`/api/${apiVersion}/gethomepage`, gethomepageRoutes);
 | 
			
		||||
app.use(`/api/${apiVersion}/automation`, automationRoutes);
 | 
			
		||||
app.use(`/api/${apiVersion}/docker`, dockerRoutes);
 | 
			
		||||
app.use(`/api/${apiVersion}/integrations`, integrationRoutes);
 | 
			
		||||
app.use(`/api/${apiVersion}/ws`, wsRoutes);
 | 
			
		||||
app.use(`/api/${apiVersion}/agent`, agentVersionRoutes);
 | 
			
		||||
app.use(`/api/${apiVersion}/metrics`, metricsRoutes);
 | 
			
		||||
 | 
			
		||||
// Bull Board - will be populated after queue manager initializes
 | 
			
		||||
let bullBoardRouter = null;
 | 
			
		||||
@@ -1198,6 +1202,15 @@ async function startServer() {
 | 
			
		||||
		initAgentWs(server, prisma);
 | 
			
		||||
		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, () => {
 | 
			
		||||
			if (process.env.ENABLE_LOGGING === "true") {
 | 
			
		||||
				logger.info(`Server running on port ${PORT}`);
 | 
			
		||||
 
 | 
			
		||||
@@ -428,26 +428,29 @@ class AgentVersionService {
 | 
			
		||||
	async getVersionInfo() {
 | 
			
		||||
		let hasUpdate = false;
 | 
			
		||||
		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
 | 
			
		||||
		if (this.currentVersion) {
 | 
			
		||||
			effectiveLatestVersion = this.currentVersion;
 | 
			
		||||
		// Latest version should ALWAYS come from GitHub, not from local binaries
 | 
			
		||||
		// currentVersion = what's installed locally
 | 
			
		||||
		// latestVersion = what's available on GitHub
 | 
			
		||||
		if (this.latestVersion) {
 | 
			
		||||
			console.log(`📦 Latest version from GitHub: ${this.latestVersion}`);
 | 
			
		||||
		} else {
 | 
			
		||||
			console.log(
 | 
			
		||||
				`🔄 Using local agent version ${this.currentVersion} as latest`,
 | 
			
		||||
			);
 | 
			
		||||
		} 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}`,
 | 
			
		||||
				`⚠️ No GitHub release version available (API may be unavailable)`,
 | 
			
		||||
			);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		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(
 | 
			
		||||
				this.currentVersion,
 | 
			
		||||
				effectiveLatestVersion,
 | 
			
		||||
				this.latestVersion,
 | 
			
		||||
			);
 | 
			
		||||
			if (comparison < 0) {
 | 
			
		||||
				hasUpdate = true;
 | 
			
		||||
@@ -459,25 +462,25 @@ class AgentVersionService {
 | 
			
		||||
				hasUpdate = false;
 | 
			
		||||
				updateStatus = "up-to-date";
 | 
			
		||||
			}
 | 
			
		||||
		} else if (effectiveLatestVersion && !this.currentVersion) {
 | 
			
		||||
		} else if (this.latestVersion && !this.currentVersion) {
 | 
			
		||||
			hasUpdate = true;
 | 
			
		||||
			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)
 | 
			
		||||
			hasUpdate = false;
 | 
			
		||||
			updateStatus = "github-unavailable";
 | 
			
		||||
		} else if (!this.currentVersion && !effectiveLatestVersion) {
 | 
			
		||||
		} else if (!this.currentVersion && !this.latestVersion) {
 | 
			
		||||
			updateStatus = "no-data";
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return {
 | 
			
		||||
			currentVersion: this.currentVersion,
 | 
			
		||||
			latestVersion: effectiveLatestVersion,
 | 
			
		||||
			latestVersion: this.latestVersion, // Always return GitHub version, not local
 | 
			
		||||
			hasUpdate: hasUpdate,
 | 
			
		||||
			updateStatus: updateStatus,
 | 
			
		||||
			lastChecked: this.lastChecked,
 | 
			
		||||
			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
 | 
			
		||||
				notifyConnectionChange(apiId, true);
 | 
			
		||||
 | 
			
		||||
				ws.on("message", () => {
 | 
			
		||||
					// Currently we don't need to handle agent->server messages
 | 
			
		||||
				ws.on("message", async (data) => {
 | 
			
		||||
					// 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", () => {
 | 
			
		||||
@@ -255,6 +269,62 @@ 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 = {
 | 
			
		||||
	init,
 | 
			
		||||
	broadcastSettingsUpdate,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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
 | 
			
		||||
			let currentVersion = "1.3.0"; // fallback
 | 
			
		||||
			let currentVersion = null;
 | 
			
		||||
			try {
 | 
			
		||||
				const packageJson = require("../../../package.json");
 | 
			
		||||
				if (packageJson?.version) {
 | 
			
		||||
					currentVersion = packageJson.version;
 | 
			
		||||
				}
 | 
			
		||||
			} catch (packageError) {
 | 
			
		||||
				console.warn(
 | 
			
		||||
				console.error(
 | 
			
		||||
					"Could not read version from package.json:",
 | 
			
		||||
					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 =
 | 
			
		||||
 
 | 
			
		||||
@@ -8,6 +8,8 @@ const GitHubUpdateCheck = require("./githubUpdateCheck");
 | 
			
		||||
const SessionCleanup = require("./sessionCleanup");
 | 
			
		||||
const OrphanedRepoCleanup = require("./orphanedRepoCleanup");
 | 
			
		||||
const OrphanedPackageCleanup = require("./orphanedPackageCleanup");
 | 
			
		||||
const DockerInventoryCleanup = require("./dockerInventoryCleanup");
 | 
			
		||||
const MetricsReporting = require("./metricsReporting");
 | 
			
		||||
 | 
			
		||||
// Queue names
 | 
			
		||||
const QUEUE_NAMES = {
 | 
			
		||||
@@ -15,6 +17,8 @@ const QUEUE_NAMES = {
 | 
			
		||||
	SESSION_CLEANUP: "session-cleanup",
 | 
			
		||||
	ORPHANED_REPO_CLEANUP: "orphaned-repo-cleanup",
 | 
			
		||||
	ORPHANED_PACKAGE_CLEANUP: "orphaned-package-cleanup",
 | 
			
		||||
	DOCKER_INVENTORY_CLEANUP: "docker-inventory-cleanup",
 | 
			
		||||
	METRICS_REPORTING: "metrics-reporting",
 | 
			
		||||
	AGENT_COMMANDS: "agent-commands",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -91,6 +95,11 @@ class QueueManager {
 | 
			
		||||
			new OrphanedRepoCleanup(this);
 | 
			
		||||
		this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP] =
 | 
			
		||||
			new OrphanedPackageCleanup(this);
 | 
			
		||||
		this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP] =
 | 
			
		||||
			new DockerInventoryCleanup(this);
 | 
			
		||||
		this.automations[QUEUE_NAMES.METRICS_REPORTING] = new MetricsReporting(
 | 
			
		||||
			this,
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		console.log("✅ All automation classes initialized");
 | 
			
		||||
	}
 | 
			
		||||
@@ -149,6 +158,24 @@ class QueueManager {
 | 
			
		||||
			workerOptions,
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		// Docker Inventory Cleanup Worker
 | 
			
		||||
		this.workers[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP] = new Worker(
 | 
			
		||||
			QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP,
 | 
			
		||||
			this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].process.bind(
 | 
			
		||||
				this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP],
 | 
			
		||||
			),
 | 
			
		||||
			workerOptions,
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		// Metrics Reporting Worker
 | 
			
		||||
		this.workers[QUEUE_NAMES.METRICS_REPORTING] = new Worker(
 | 
			
		||||
			QUEUE_NAMES.METRICS_REPORTING,
 | 
			
		||||
			this.automations[QUEUE_NAMES.METRICS_REPORTING].process.bind(
 | 
			
		||||
				this.automations[QUEUE_NAMES.METRICS_REPORTING],
 | 
			
		||||
			),
 | 
			
		||||
			workerOptions,
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		// Agent Commands Worker
 | 
			
		||||
		this.workers[QUEUE_NAMES.AGENT_COMMANDS] = new Worker(
 | 
			
		||||
			QUEUE_NAMES.AGENT_COMMANDS,
 | 
			
		||||
@@ -205,6 +232,8 @@ class QueueManager {
 | 
			
		||||
		await this.automations[QUEUE_NAMES.SESSION_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.DOCKER_INVENTORY_CLEANUP].schedule();
 | 
			
		||||
		await this.automations[QUEUE_NAMES.METRICS_REPORTING].schedule();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
@@ -228,6 +257,16 @@ class QueueManager {
 | 
			
		||||
		].triggerManual();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async triggerDockerInventoryCleanup() {
 | 
			
		||||
		return this.automations[
 | 
			
		||||
			QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP
 | 
			
		||||
		].triggerManual();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async triggerMetricsReporting() {
 | 
			
		||||
		return this.automations[QUEUE_NAMES.METRICS_REPORTING].triggerManual();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Get queue statistics
 | 
			
		||||
	 */
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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 {
 | 
			
		||||
		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 {
 | 
			
		||||
			const packageJson = require("../../../package.json");
 | 
			
		||||
			if (packageJson?.version) {
 | 
			
		||||
@@ -41,7 +42,7 @@ async function checkPublicRepo(owner, repo) {
 | 
			
		||||
			}
 | 
			
		||||
		} catch (packageError) {
 | 
			
		||||
			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,
 | 
			
		||||
			);
 | 
			
		||||
		}
 | 
			
		||||
 
 | 
			
		||||
@@ -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": {
 | 
			
		||||
		"enabled": true,
 | 
			
		||||
		"clientKind": "git",
 | 
			
		||||
		"useIgnoreFile": true
 | 
			
		||||
	},
 | 
			
		||||
	"files": {
 | 
			
		||||
		"includes": ["**", "!**/*.css"]
 | 
			
		||||
	},
 | 
			
		||||
	"formatter": {
 | 
			
		||||
		"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_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
 | 
			
		||||
 | 
			
		||||
| Variable        | Description                    | Default |
 | 
			
		||||
 
 | 
			
		||||
@@ -46,8 +46,10 @@ COPY --chown=node:node backend/ ./backend/
 | 
			
		||||
 | 
			
		||||
WORKDIR /app/backend
 | 
			
		||||
 | 
			
		||||
RUN npm ci --ignore-scripts &&\
 | 
			
		||||
    npx prisma generate &&\
 | 
			
		||||
RUN npm cache clean --force &&\
 | 
			
		||||
    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 cache clean --force
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -50,6 +50,12 @@ services:
 | 
			
		||||
      SERVER_HOST: localhost
 | 
			
		||||
      SERVER_PORT: 3000
 | 
			
		||||
      CORS_ORIGIN: http://localhost:3000
 | 
			
		||||
      # Database Connection Pool Configuration (Prisma)
 | 
			
		||||
      DB_CONNECTION_LIMIT: 30
 | 
			
		||||
      DB_POOL_TIMEOUT: 20
 | 
			
		||||
      DB_CONNECT_TIMEOUT: 10
 | 
			
		||||
      DB_IDLE_TIMEOUT: 300
 | 
			
		||||
      DB_MAX_LIFETIME: 1800
 | 
			
		||||
      # Rate Limiting (times in milliseconds)
 | 
			
		||||
      RATE_LIMIT_WINDOW_MS: 900000
 | 
			
		||||
      RATE_LIMIT_MAX: 5000
 | 
			
		||||
 
 | 
			
		||||
@@ -56,6 +56,12 @@ services:
 | 
			
		||||
      SERVER_HOST: localhost
 | 
			
		||||
      SERVER_PORT: 3000
 | 
			
		||||
      CORS_ORIGIN: http://localhost:3000
 | 
			
		||||
      # Database Connection Pool Configuration (Prisma)
 | 
			
		||||
      DB_CONNECTION_LIMIT: 30
 | 
			
		||||
      DB_POOL_TIMEOUT: 20
 | 
			
		||||
      DB_CONNECT_TIMEOUT: 10
 | 
			
		||||
      DB_IDLE_TIMEOUT: 300
 | 
			
		||||
      DB_MAX_LIFETIME: 1800
 | 
			
		||||
      # Rate Limiting (times in milliseconds)
 | 
			
		||||
      RATE_LIMIT_WINDOW_MS: 900000
 | 
			
		||||
      RATE_LIMIT_MAX: 5000
 | 
			
		||||
 
 | 
			
		||||
@@ -17,16 +17,21 @@ CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
 | 
			
		||||
# Builder stage for production
 | 
			
		||||
FROM node:lts-alpine AS builder
 | 
			
		||||
 | 
			
		||||
WORKDIR /app
 | 
			
		||||
WORKDIR /app/frontend
 | 
			
		||||
 | 
			
		||||
COPY package*.json ./
 | 
			
		||||
COPY frontend/package*.json ./frontend/
 | 
			
		||||
COPY frontend/package*.json ./
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
FROM nginxinc/nginx-unprivileged:alpine
 | 
			
		||||
 
 | 
			
		||||
@@ -6,5 +6,5 @@ VITE_API_URL=http://localhost:3001/api/v1
 | 
			
		||||
 | 
			
		||||
# Application Metadata
 | 
			
		||||
VITE_APP_NAME=PatchMon
 | 
			
		||||
VITE_APP_VERSION=1.3.0
 | 
			
		||||
VITE_APP_VERSION=1.3.1
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
{
 | 
			
		||||
	"name": "patchmon-frontend",
 | 
			
		||||
	"private": true,
 | 
			
		||||
	"version": "1.3.0",
 | 
			
		||||
	"version": "1.3.1",
 | 
			
		||||
	"license": "AGPL-3.0",
 | 
			
		||||
	"type": "module",
 | 
			
		||||
	"scripts": {
 | 
			
		||||
@@ -35,7 +35,7 @@
 | 
			
		||||
		"@vitejs/plugin-react": "^4.3.4",
 | 
			
		||||
		"autoprefixer": "^10.4.20",
 | 
			
		||||
		"postcss": "^8.5.6",
 | 
			
		||||
		"tailwindcss": "^3.4.17",
 | 
			
		||||
		"tailwindcss": "^4.0.0",
 | 
			
		||||
		"vite": "^7.1.5"
 | 
			
		||||
	},
 | 
			
		||||
	"overrides": {
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@ import ProtectedRoute from "./components/ProtectedRoute";
 | 
			
		||||
import SettingsLayout from "./components/SettingsLayout";
 | 
			
		||||
import { isAuthPhase } from "./constants/authPhases";
 | 
			
		||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
 | 
			
		||||
import { ColorThemeProvider } from "./contexts/ColorThemeContext";
 | 
			
		||||
import { ThemeProvider } from "./contexts/ThemeContext";
 | 
			
		||||
import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
 | 
			
		||||
 | 
			
		||||
@@ -41,6 +42,7 @@ const SettingsServerConfig = lazy(
 | 
			
		||||
	() => import("./pages/settings/SettingsServerConfig"),
 | 
			
		||||
);
 | 
			
		||||
const SettingsUsers = lazy(() => import("./pages/settings/SettingsUsers"));
 | 
			
		||||
const SettingsMetrics = lazy(() => import("./pages/settings/SettingsMetrics"));
 | 
			
		||||
 | 
			
		||||
// Loading fallback component
 | 
			
		||||
const LoadingFallback = () => (
 | 
			
		||||
@@ -388,6 +390,16 @@ function AppRoutes() {
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/settings/metrics"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<SettingsMetrics />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/options"
 | 
			
		||||
					element={
 | 
			
		||||
@@ -416,13 +428,15 @@ function AppRoutes() {
 | 
			
		||||
function App() {
 | 
			
		||||
	return (
 | 
			
		||||
		<ThemeProvider>
 | 
			
		||||
			<AuthProvider>
 | 
			
		||||
				<UpdateNotificationProvider>
 | 
			
		||||
					<LogoProvider>
 | 
			
		||||
						<AppRoutes />
 | 
			
		||||
					</LogoProvider>
 | 
			
		||||
				</UpdateNotificationProvider>
 | 
			
		||||
			</AuthProvider>
 | 
			
		||||
			<ColorThemeProvider>
 | 
			
		||||
				<AuthProvider>
 | 
			
		||||
					<UpdateNotificationProvider>
 | 
			
		||||
						<LogoProvider>
 | 
			
		||||
							<AppRoutes />
 | 
			
		||||
						</LogoProvider>
 | 
			
		||||
					</UpdateNotificationProvider>
 | 
			
		||||
				</AuthProvider>
 | 
			
		||||
			</ColorThemeProvider>
 | 
			
		||||
		</ThemeProvider>
 | 
			
		||||
	);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -26,9 +26,10 @@ import {
 | 
			
		||||
	Zap,
 | 
			
		||||
} from "lucide-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 { useAuth } from "../contexts/AuthContext";
 | 
			
		||||
import { useColorTheme } from "../contexts/ColorThemeContext";
 | 
			
		||||
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
 | 
			
		||||
import { dashboardAPI, versionAPI } from "../utils/api";
 | 
			
		||||
import DiscordIcon from "./DiscordIcon";
 | 
			
		||||
@@ -61,7 +62,9 @@ const Layout = ({ children }) => {
 | 
			
		||||
		canManageSettings,
 | 
			
		||||
	} = useAuth();
 | 
			
		||||
	const { updateAvailable } = useUpdateNotification();
 | 
			
		||||
	const { themeConfig } = useColorTheme();
 | 
			
		||||
	const userMenuRef = useRef(null);
 | 
			
		||||
	const bgCanvasRef = useRef(null);
 | 
			
		||||
 | 
			
		||||
	// Fetch dashboard stats for the "Last updated" info
 | 
			
		||||
	const {
 | 
			
		||||
@@ -233,27 +236,165 @@ const Layout = ({ children }) => {
 | 
			
		||||
		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
 | 
			
		||||
	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 now = Date.now();
 | 
			
		||||
		if (lastFetch && now - parseInt(lastFetch, 15) < 600000) {
 | 
			
		||||
			// 15 minute cache
 | 
			
		||||
		if (lastFetch && now - parseInt(lastFetch, 10) < 600000) {
 | 
			
		||||
			// 10 minute cache
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		try {
 | 
			
		||||
			const response = await fetch(
 | 
			
		||||
				"https://api.github.com/repos/9technologygroup/patchmon.net",
 | 
			
		||||
				{
 | 
			
		||||
					headers: {
 | 
			
		||||
						Accept: "application/vnd.github.v3+json",
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
			);
 | 
			
		||||
 | 
			
		||||
			if (response.ok) {
 | 
			
		||||
				const data = await response.json();
 | 
			
		||||
				setGithubStars(data.stargazers_count);
 | 
			
		||||
				localStorage.setItem(
 | 
			
		||||
					"githubStarsCount",
 | 
			
		||||
					data.stargazers_count.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) {
 | 
			
		||||
			console.error("Failed to fetch GitHub stars:", error);
 | 
			
		||||
			// Keep using cached value if available
 | 
			
		||||
		}
 | 
			
		||||
	}, []);
 | 
			
		||||
 | 
			
		||||
@@ -303,11 +444,76 @@ const Layout = ({ children }) => {
 | 
			
		||||
		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 (
 | 
			
		||||
		<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 */}
 | 
			
		||||
			<div
 | 
			
		||||
				className={`fixed inset-0 z-50 lg:hidden ${sidebarOpen ? "block" : "hidden"}`}
 | 
			
		||||
				className={`fixed inset-0 z-[60] lg:hidden ${sidebarOpen ? "block" : "hidden"}`}
 | 
			
		||||
			>
 | 
			
		||||
				<button
 | 
			
		||||
					type="button"
 | 
			
		||||
@@ -315,7 +521,14 @@ const Layout = ({ children }) => {
 | 
			
		||||
					onClick={() => setSidebarOpen(false)}
 | 
			
		||||
					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">
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
@@ -534,17 +747,43 @@ const Layout = ({ children }) => {
 | 
			
		||||
 | 
			
		||||
			{/* Desktop sidebar */}
 | 
			
		||||
			<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"
 | 
			
		||||
				} 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
 | 
			
		||||
					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"
 | 
			
		||||
					}`}
 | 
			
		||||
					style={{
 | 
			
		||||
						backgroundColor: "var(--sidebar-bg, white)",
 | 
			
		||||
						backdropFilter: "var(--sidebar-blur, none)",
 | 
			
		||||
						WebkitBackdropFilter: "var(--sidebar-blur, none)",
 | 
			
		||||
						overflowY: "auto",
 | 
			
		||||
						overflowX: "visible",
 | 
			
		||||
					}}
 | 
			
		||||
				>
 | 
			
		||||
					<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"
 | 
			
		||||
						}`}
 | 
			
		||||
					>
 | 
			
		||||
@@ -562,19 +801,6 @@ const Layout = ({ children }) => {
 | 
			
		||||
							</Link>
 | 
			
		||||
						)}
 | 
			
		||||
					</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">
 | 
			
		||||
						<ul className="flex flex-1 flex-col gap-y-6">
 | 
			
		||||
							{/* Show message for users with very limited permissions */}
 | 
			
		||||
@@ -930,12 +1156,19 @@ const Layout = ({ children }) => {
 | 
			
		||||
 | 
			
		||||
			{/* Main content */}
 | 
			
		||||
			<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"
 | 
			
		||||
				}`}
 | 
			
		||||
			>
 | 
			
		||||
				{/* 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
 | 
			
		||||
						type="button"
 | 
			
		||||
						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" />
 | 
			
		||||
									{githubStars !== null && (
 | 
			
		||||
										<div className="flex items-center gap-0.5">
 | 
			
		||||
											<Star className="h-3 w-3 fill-current text-yellow-500" />
 | 
			
		||||
										<div className="flex items-center gap-1">
 | 
			
		||||
											<Star className="h-4 w-4 fill-current text-yellow-500" />
 | 
			
		||||
											<span className="text-sm font-medium">{githubStars}</span>
 | 
			
		||||
										</div>
 | 
			
		||||
									)}
 | 
			
		||||
@@ -1059,7 +1292,17 @@ const Layout = ({ children }) => {
 | 
			
		||||
								>
 | 
			
		||||
									<FaYoutube className="h-5 w-5" />
 | 
			
		||||
								</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
 | 
			
		||||
									href="https://patchmon.net"
 | 
			
		||||
									target="_blank"
 | 
			
		||||
@@ -1074,7 +1317,7 @@ const Layout = ({ children }) => {
 | 
			
		||||
					</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>
 | 
			
		||||
				</main>
 | 
			
		||||
			</div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
import {
 | 
			
		||||
	BarChart3,
 | 
			
		||||
	Bell,
 | 
			
		||||
	ChevronLeft,
 | 
			
		||||
	ChevronRight,
 | 
			
		||||
@@ -141,6 +142,11 @@ const SettingsLayout = ({ children }) => {
 | 
			
		||||
						href: "/settings/server-version",
 | 
			
		||||
						icon: Code,
 | 
			
		||||
					},
 | 
			
		||||
					{
 | 
			
		||||
						name: "Metrics",
 | 
			
		||||
						href: "/settings/metrics",
 | 
			
		||||
						icon: BarChart3,
 | 
			
		||||
					},
 | 
			
		||||
				],
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,14 @@
 | 
			
		||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 | 
			
		||||
import { AlertCircle, Image, RotateCcw, Upload, X } from "lucide-react";
 | 
			
		||||
import {
 | 
			
		||||
	AlertCircle,
 | 
			
		||||
	Image,
 | 
			
		||||
	Palette,
 | 
			
		||||
	RotateCcw,
 | 
			
		||||
	Upload,
 | 
			
		||||
	X,
 | 
			
		||||
} from "lucide-react";
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import { THEME_PRESETS, useColorTheme } from "../../contexts/ColorThemeContext";
 | 
			
		||||
import { settingsAPI } from "../../utils/api";
 | 
			
		||||
 | 
			
		||||
const BrandingTab = () => {
 | 
			
		||||
@@ -12,6 +20,7 @@ const BrandingTab = () => {
 | 
			
		||||
	});
 | 
			
		||||
	const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
 | 
			
		||||
	const [selectedLogoType, setSelectedLogoType] = useState("dark");
 | 
			
		||||
	const { colorTheme, setColorTheme } = useColorTheme();
 | 
			
		||||
 | 
			
		||||
	const queryClient = useQueryClient();
 | 
			
		||||
 | 
			
		||||
@@ -75,6 +84,22 @@ const BrandingTab = () => {
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Theme update mutation
 | 
			
		||||
	const updateThemeMutation = useMutation({
 | 
			
		||||
		mutationFn: (theme) => settingsAPI.update({ colorTheme: theme }),
 | 
			
		||||
		onSuccess: (_data, theme) => {
 | 
			
		||||
			queryClient.invalidateQueries(["settings"]);
 | 
			
		||||
			setColorTheme(theme);
 | 
			
		||||
		},
 | 
			
		||||
		onError: (error) => {
 | 
			
		||||
			console.error("Update theme error:", error);
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	const handleThemeChange = (theme) => {
 | 
			
		||||
		updateThemeMutation.mutate(theme);
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	if (isLoading) {
 | 
			
		||||
		return (
 | 
			
		||||
			<div className="flex items-center justify-center h-64">
 | 
			
		||||
@@ -102,17 +127,110 @@ const BrandingTab = () => {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="space-y-6">
 | 
			
		||||
			<div className="flex items-center mb-6">
 | 
			
		||||
				<Image className="h-6 w-6 text-primary-600 mr-3" />
 | 
			
		||||
				<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
			
		||||
					Logo & Branding
 | 
			
		||||
				</h2>
 | 
			
		||||
		<div className="space-y-8">
 | 
			
		||||
			{/* Header */}
 | 
			
		||||
			<div>
 | 
			
		||||
				<div className="flex items-center mb-6">
 | 
			
		||||
					<Image className="h-6 w-6 text-primary-600 mr-3" />
 | 
			
		||||
					<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, favicon, and
 | 
			
		||||
					color themes. These will be displayed throughout the application.
 | 
			
		||||
				</p>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			{/* Color Theme Selector */}
 | 
			
		||||
			<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
				<div className="flex items-center mb-4">
 | 
			
		||||
					<Palette className="h-5 w-5 text-primary-600 mr-2" />
 | 
			
		||||
					<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
 | 
			
		||||
						Color Theme
 | 
			
		||||
					</h3>
 | 
			
		||||
				</div>
 | 
			
		||||
				<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
 | 
			
		||||
					Choose a color theme that will be applied to the login page and
 | 
			
		||||
					background areas throughout the app.
 | 
			
		||||
				</p>
 | 
			
		||||
 | 
			
		||||
				<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
 | 
			
		||||
					{Object.entries(THEME_PRESETS).map(([themeKey, theme]) => {
 | 
			
		||||
						const isSelected = colorTheme === themeKey;
 | 
			
		||||
						const gradientColors = theme.login.xColors;
 | 
			
		||||
 | 
			
		||||
						return (
 | 
			
		||||
							<button
 | 
			
		||||
								key={themeKey}
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={() => handleThemeChange(themeKey)}
 | 
			
		||||
								disabled={updateThemeMutation.isPending}
 | 
			
		||||
								className={`relative p-4 rounded-lg border-2 transition-all ${
 | 
			
		||||
									isSelected
 | 
			
		||||
										? "border-primary-500 ring-2 ring-primary-200 dark:ring-primary-800"
 | 
			
		||||
										: "border-secondary-200 dark:border-secondary-600 hover:border-primary-300"
 | 
			
		||||
								} ${updateThemeMutation.isPending ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
 | 
			
		||||
							>
 | 
			
		||||
								{/* Theme Preview */}
 | 
			
		||||
								<div
 | 
			
		||||
									className="h-20 rounded-md mb-3 overflow-hidden"
 | 
			
		||||
									style={{
 | 
			
		||||
										background: `linear-gradient(135deg, ${gradientColors.join(", ")})`,
 | 
			
		||||
									}}
 | 
			
		||||
								/>
 | 
			
		||||
 | 
			
		||||
								{/* Theme Name */}
 | 
			
		||||
								<div className="text-sm font-medium text-secondary-900 dark:text-white mb-1">
 | 
			
		||||
									{theme.name}
 | 
			
		||||
								</div>
 | 
			
		||||
 | 
			
		||||
								{/* Selected Indicator */}
 | 
			
		||||
								{isSelected && (
 | 
			
		||||
									<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
 | 
			
		||||
										<svg
 | 
			
		||||
											className="w-4 h-4"
 | 
			
		||||
											fill="currentColor"
 | 
			
		||||
											viewBox="0 0 20 20"
 | 
			
		||||
											aria-label="Selected theme"
 | 
			
		||||
										>
 | 
			
		||||
											<title>Selected</title>
 | 
			
		||||
											<path
 | 
			
		||||
												fillRule="evenodd"
 | 
			
		||||
												d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
 | 
			
		||||
												clipRule="evenodd"
 | 
			
		||||
											/>
 | 
			
		||||
										</svg>
 | 
			
		||||
									</div>
 | 
			
		||||
								)}
 | 
			
		||||
							</button>
 | 
			
		||||
						);
 | 
			
		||||
					})}
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				{updateThemeMutation.isPending && (
 | 
			
		||||
					<div className="mt-4 flex items-center gap-2 text-sm text-primary-600 dark:text-primary-400">
 | 
			
		||||
						<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
 | 
			
		||||
						Updating theme...
 | 
			
		||||
					</div>
 | 
			
		||||
				)}
 | 
			
		||||
 | 
			
		||||
				{updateThemeMutation.isError && (
 | 
			
		||||
					<div className="mt-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
 | 
			
		||||
						<p className="text-sm text-red-800 dark:text-red-200">
 | 
			
		||||
							Failed to update theme: {updateThemeMutation.error?.message}
 | 
			
		||||
						</p>
 | 
			
		||||
					</div>
 | 
			
		||||
				)}
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			{/* Logo Section Header */}
 | 
			
		||||
			<div className="flex items-center mb-4">
 | 
			
		||||
				<Image className="h-5 w-5 text-primary-600 mr-2" />
 | 
			
		||||
				<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
 | 
			
		||||
					Logos
 | 
			
		||||
				</h3>
 | 
			
		||||
			</div>
 | 
			
		||||
			<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">
 | 
			
		||||
				{/* Dark Logo */}
 | 
			
		||||
 
 | 
			
		||||
@@ -54,7 +54,7 @@ const UsersTab = () => {
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Update user mutation
 | 
			
		||||
	const _updateUserMutation = useMutation({
 | 
			
		||||
	const updateUserMutation = useMutation({
 | 
			
		||||
		mutationFn: ({ id, data }) => adminUsersAPI.update(id, data),
 | 
			
		||||
		onSuccess: () => {
 | 
			
		||||
			queryClient.invalidateQueries(["users"]);
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										194
									
								
								frontend/src/contexts/ColorThemeContext.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										194
									
								
								frontend/src/contexts/ColorThemeContext.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,194 @@
 | 
			
		||||
import { createContext, useContext, useEffect, useState } from "react";
 | 
			
		||||
 | 
			
		||||
const ColorThemeContext = createContext();
 | 
			
		||||
 | 
			
		||||
// Theme configurations matching the login backgrounds
 | 
			
		||||
export const THEME_PRESETS = {
 | 
			
		||||
	default: {
 | 
			
		||||
		name: "Normal Dark",
 | 
			
		||||
		login: {
 | 
			
		||||
			cellSize: 90,
 | 
			
		||||
			variance: 0.85,
 | 
			
		||||
			xColors: ["#0f172a", "#1e293b", "#334155", "#475569", "#64748b"],
 | 
			
		||||
			yColors: ["#0f172a", "#1e293b", "#334155", "#475569", "#64748b"],
 | 
			
		||||
		},
 | 
			
		||||
		app: {
 | 
			
		||||
			bgPrimary: "#1e293b",
 | 
			
		||||
			bgSecondary: "#1e293b",
 | 
			
		||||
			bgTertiary: "#334155",
 | 
			
		||||
			borderColor: "#475569",
 | 
			
		||||
			cardBg: "#1e293b",
 | 
			
		||||
			cardBorder: "#334155",
 | 
			
		||||
			buttonBg: "#334155",
 | 
			
		||||
			buttonHover: "#475569",
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
	cyber_blue: {
 | 
			
		||||
		name: "Cyber Blue",
 | 
			
		||||
		login: {
 | 
			
		||||
			cellSize: 90,
 | 
			
		||||
			variance: 0.85,
 | 
			
		||||
			xColors: ["#0a0820", "#1a1f3a", "#2d3561", "#4a5584", "#667eaf"],
 | 
			
		||||
			yColors: ["#0a0820", "#1a1f3a", "#2d3561", "#4a5584", "#667eaf"],
 | 
			
		||||
		},
 | 
			
		||||
		app: {
 | 
			
		||||
			bgPrimary: "#0a0820",
 | 
			
		||||
			bgSecondary: "#1a1f3a",
 | 
			
		||||
			bgTertiary: "#2d3561",
 | 
			
		||||
			borderColor: "#4a5584",
 | 
			
		||||
			cardBg: "#1a1f3a",
 | 
			
		||||
			cardBorder: "#2d3561",
 | 
			
		||||
			buttonBg: "#2d3561",
 | 
			
		||||
			buttonHover: "#4a5584",
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
	neon_purple: {
 | 
			
		||||
		name: "Neon Purple",
 | 
			
		||||
		login: {
 | 
			
		||||
			cellSize: 80,
 | 
			
		||||
			variance: 0.9,
 | 
			
		||||
			xColors: ["#0f0a1e", "#1e0f3e", "#4a0082", "#7209b7", "#b5179e"],
 | 
			
		||||
			yColors: ["#0f0a1e", "#1e0f3e", "#4a0082", "#7209b7", "#b5179e"],
 | 
			
		||||
		},
 | 
			
		||||
		app: {
 | 
			
		||||
			bgPrimary: "#0f0a1e",
 | 
			
		||||
			bgSecondary: "#1e0f3e",
 | 
			
		||||
			bgTertiary: "#4a0082",
 | 
			
		||||
			borderColor: "#7209b7",
 | 
			
		||||
			cardBg: "#1e0f3e",
 | 
			
		||||
			cardBorder: "#4a0082",
 | 
			
		||||
			buttonBg: "#4a0082",
 | 
			
		||||
			buttonHover: "#7209b7",
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
	matrix_green: {
 | 
			
		||||
		name: "Matrix Green",
 | 
			
		||||
		login: {
 | 
			
		||||
			cellSize: 70,
 | 
			
		||||
			variance: 0.7,
 | 
			
		||||
			xColors: ["#001a00", "#003300", "#004d00", "#006600", "#00b300"],
 | 
			
		||||
			yColors: ["#001a00", "#003300", "#004d00", "#006600", "#00b300"],
 | 
			
		||||
		},
 | 
			
		||||
		app: {
 | 
			
		||||
			bgPrimary: "#001a00",
 | 
			
		||||
			bgSecondary: "#003300",
 | 
			
		||||
			bgTertiary: "#004d00",
 | 
			
		||||
			borderColor: "#006600",
 | 
			
		||||
			cardBg: "#003300",
 | 
			
		||||
			cardBorder: "#004d00",
 | 
			
		||||
			buttonBg: "#004d00",
 | 
			
		||||
			buttonHover: "#006600",
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
	ocean_blue: {
 | 
			
		||||
		name: "Ocean Blue",
 | 
			
		||||
		login: {
 | 
			
		||||
			cellSize: 85,
 | 
			
		||||
			variance: 0.8,
 | 
			
		||||
			xColors: ["#001845", "#023e7d", "#0077b6", "#0096c7", "#00b4d8"],
 | 
			
		||||
			yColors: ["#001845", "#023e7d", "#0077b6", "#0096c7", "#00b4d8"],
 | 
			
		||||
		},
 | 
			
		||||
		app: {
 | 
			
		||||
			bgPrimary: "#001845",
 | 
			
		||||
			bgSecondary: "#023e7d",
 | 
			
		||||
			bgTertiary: "#0077b6",
 | 
			
		||||
			borderColor: "#0096c7",
 | 
			
		||||
			cardBg: "#023e7d",
 | 
			
		||||
			cardBorder: "#0077b6",
 | 
			
		||||
			buttonBg: "#0077b6",
 | 
			
		||||
			buttonHover: "#0096c7",
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
	sunset_gradient: {
 | 
			
		||||
		name: "Sunset Gradient",
 | 
			
		||||
		login: {
 | 
			
		||||
			cellSize: 95,
 | 
			
		||||
			variance: 0.75,
 | 
			
		||||
			xColors: ["#1a0033", "#330066", "#4d0099", "#6600cc", "#9933ff"],
 | 
			
		||||
			yColors: ["#1a0033", "#660033", "#990033", "#cc0066", "#ff0099"],
 | 
			
		||||
		},
 | 
			
		||||
		app: {
 | 
			
		||||
			bgPrimary: "#1a0033",
 | 
			
		||||
			bgSecondary: "#330066",
 | 
			
		||||
			bgTertiary: "#4d0099",
 | 
			
		||||
			borderColor: "#6600cc",
 | 
			
		||||
			cardBg: "#330066",
 | 
			
		||||
			cardBorder: "#4d0099",
 | 
			
		||||
			buttonBg: "#4d0099",
 | 
			
		||||
			buttonHover: "#6600cc",
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const ColorThemeProvider = ({ children }) => {
 | 
			
		||||
	const [colorTheme, setColorTheme] = useState("default");
 | 
			
		||||
	const [isLoading, setIsLoading] = useState(true);
 | 
			
		||||
 | 
			
		||||
	// Fetch theme from settings on mount
 | 
			
		||||
	useEffect(() => {
 | 
			
		||||
		const fetchTheme = async () => {
 | 
			
		||||
			try {
 | 
			
		||||
				// Check localStorage first for unauthenticated pages (login)
 | 
			
		||||
				const cachedTheme = localStorage.getItem("colorTheme");
 | 
			
		||||
				if (cachedTheme) {
 | 
			
		||||
					setColorTheme(cachedTheme);
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				// Try to fetch from API (will fail on login page, that's ok)
 | 
			
		||||
				try {
 | 
			
		||||
					const token = localStorage.getItem("token");
 | 
			
		||||
					if (token) {
 | 
			
		||||
						const response = await fetch("/api/v1/settings", {
 | 
			
		||||
							headers: {
 | 
			
		||||
								Authorization: `Bearer ${token}`,
 | 
			
		||||
							},
 | 
			
		||||
						});
 | 
			
		||||
 | 
			
		||||
						if (response.ok) {
 | 
			
		||||
							const data = await response.json();
 | 
			
		||||
							if (data.color_theme) {
 | 
			
		||||
								setColorTheme(data.color_theme);
 | 
			
		||||
								localStorage.setItem("colorTheme", data.color_theme);
 | 
			
		||||
							}
 | 
			
		||||
						}
 | 
			
		||||
					}
 | 
			
		||||
				} catch (_apiError) {
 | 
			
		||||
					// Silent fail - use cached or default theme
 | 
			
		||||
					console.log("Could not fetch theme from API, using cached/default");
 | 
			
		||||
				}
 | 
			
		||||
			} catch (error) {
 | 
			
		||||
				console.error("Error loading color theme:", error);
 | 
			
		||||
			} finally {
 | 
			
		||||
				setIsLoading(false);
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		fetchTheme();
 | 
			
		||||
	}, []);
 | 
			
		||||
 | 
			
		||||
	const updateColorTheme = (theme) => {
 | 
			
		||||
		setColorTheme(theme);
 | 
			
		||||
		localStorage.setItem("colorTheme", theme);
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const value = {
 | 
			
		||||
		colorTheme,
 | 
			
		||||
		setColorTheme: updateColorTheme,
 | 
			
		||||
		themeConfig: THEME_PRESETS[colorTheme] || THEME_PRESETS.default,
 | 
			
		||||
		isLoading,
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<ColorThemeContext.Provider value={value}>
 | 
			
		||||
			{children}
 | 
			
		||||
		</ColorThemeContext.Provider>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useColorTheme = () => {
 | 
			
		||||
	const context = useContext(ColorThemeContext);
 | 
			
		||||
	if (!context) {
 | 
			
		||||
		throw new Error("useColorTheme must be used within ColorThemeProvider");
 | 
			
		||||
	}
 | 
			
		||||
	return context;
 | 
			
		||||
};
 | 
			
		||||
@@ -9,7 +9,7 @@
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	body {
 | 
			
		||||
		@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 {
 | 
			
		||||
		@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 {
 | 
			
		||||
		@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 {
 | 
			
		||||
		@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 {
 | 
			
		||||
		@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 {
 | 
			
		||||
@@ -84,6 +111,27 @@
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@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: 0 1px 2px rgba(0, 0, 0, 0.05);
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
@@ -169,6 +169,20 @@ const Automation = () => {
 | 
			
		||||
				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") {
 | 
			
		||||
			const now = new Date();
 | 
			
		||||
			const nextHour = new Date(now);
 | 
			
		||||
@@ -209,6 +223,13 @@ const Automation = () => {
 | 
			
		||||
			tomorrow.setHours(3, 0, 0, 0);
 | 
			
		||||
			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") {
 | 
			
		||||
			const now = new Date();
 | 
			
		||||
			const nextHour = new Date(now);
 | 
			
		||||
@@ -269,6 +290,8 @@ const Automation = () => {
 | 
			
		||||
				endpoint = "/automation/trigger/orphaned-repo-cleanup";
 | 
			
		||||
			} else if (jobType === "orphaned-packages") {
 | 
			
		||||
				endpoint = "/automation/trigger/orphaned-package-cleanup";
 | 
			
		||||
			} else if (jobType === "docker-inventory") {
 | 
			
		||||
				endpoint = "/automation/trigger/docker-inventory-cleanup";
 | 
			
		||||
			} else if (jobType === "agent-collection") {
 | 
			
		||||
				endpoint = "/automation/trigger/agent-collection";
 | 
			
		||||
			}
 | 
			
		||||
@@ -584,6 +607,10 @@ const Automation = () => {
 | 
			
		||||
																automation.queue.includes("orphaned-package")
 | 
			
		||||
															) {
 | 
			
		||||
																triggerManualJob("orphaned-packages");
 | 
			
		||||
															} else if (
 | 
			
		||||
																automation.queue.includes("docker-inventory")
 | 
			
		||||
															) {
 | 
			
		||||
																triggerManualJob("docker-inventory");
 | 
			
		||||
															} else if (
 | 
			
		||||
																automation.queue.includes("agent-commands")
 | 
			
		||||
															) {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { useQuery } from "@tanstack/react-query";
 | 
			
		||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 | 
			
		||||
import {
 | 
			
		||||
	AlertTriangle,
 | 
			
		||||
	ArrowDown,
 | 
			
		||||
@@ -11,6 +11,7 @@ import {
 | 
			
		||||
	Search,
 | 
			
		||||
	Server,
 | 
			
		||||
	Shield,
 | 
			
		||||
	Trash2,
 | 
			
		||||
	X,
 | 
			
		||||
} from "lucide-react";
 | 
			
		||||
import { useMemo, useState } from "react";
 | 
			
		||||
@@ -18,12 +19,15 @@ import { Link } from "react-router-dom";
 | 
			
		||||
import api from "../utils/api";
 | 
			
		||||
 | 
			
		||||
const Docker = () => {
 | 
			
		||||
	const queryClient = useQueryClient();
 | 
			
		||||
	const [searchTerm, setSearchTerm] = useState("");
 | 
			
		||||
	const [activeTab, setActiveTab] = useState("containers");
 | 
			
		||||
	const [sortField, setSortField] = useState("status");
 | 
			
		||||
	const [sortDirection, setSortDirection] = useState("asc");
 | 
			
		||||
	const [statusFilter, setStatusFilter] = useState("all");
 | 
			
		||||
	const [sourceFilter, setSourceFilter] = useState("all");
 | 
			
		||||
	const [deleteContainerModal, setDeleteContainerModal] = useState(null);
 | 
			
		||||
	const [deleteImageModal, setDeleteImageModal] = useState(null);
 | 
			
		||||
 | 
			
		||||
	// Fetch Docker dashboard data
 | 
			
		||||
	const { data: dashboard, isLoading: dashboardLoading } = useQuery({
 | 
			
		||||
@@ -36,7 +40,11 @@ const Docker = () => {
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Fetch containers
 | 
			
		||||
	const { data: containersData, isLoading: containersLoading } = useQuery({
 | 
			
		||||
	const {
 | 
			
		||||
		data: containersData,
 | 
			
		||||
		isLoading: containersLoading,
 | 
			
		||||
		refetch: refetchContainers,
 | 
			
		||||
	} = useQuery({
 | 
			
		||||
		queryKey: ["docker", "containers", statusFilter],
 | 
			
		||||
		queryFn: async () => {
 | 
			
		||||
			const params = new URLSearchParams();
 | 
			
		||||
@@ -49,7 +57,11 @@ const Docker = () => {
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Fetch images
 | 
			
		||||
	const { data: imagesData, isLoading: imagesLoading } = useQuery({
 | 
			
		||||
	const {
 | 
			
		||||
		data: imagesData,
 | 
			
		||||
		isLoading: imagesLoading,
 | 
			
		||||
		refetch: refetchImages,
 | 
			
		||||
	} = useQuery({
 | 
			
		||||
		queryKey: ["docker", "images", sourceFilter],
 | 
			
		||||
		queryFn: async () => {
 | 
			
		||||
			const params = new URLSearchParams();
 | 
			
		||||
@@ -81,6 +93,42 @@ const Docker = () => {
 | 
			
		||||
		enabled: activeTab === "updates",
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Delete container mutation
 | 
			
		||||
	const deleteContainerMutation = useMutation({
 | 
			
		||||
		mutationFn: async (containerId) => {
 | 
			
		||||
			const response = await api.delete(`/docker/containers/${containerId}`);
 | 
			
		||||
			return response.data;
 | 
			
		||||
		},
 | 
			
		||||
		onSuccess: () => {
 | 
			
		||||
			queryClient.invalidateQueries(["docker", "containers"]);
 | 
			
		||||
			queryClient.invalidateQueries(["docker", "dashboard"]);
 | 
			
		||||
			setDeleteContainerModal(null);
 | 
			
		||||
		},
 | 
			
		||||
		onError: (error) => {
 | 
			
		||||
			alert(
 | 
			
		||||
				`Failed to delete container: ${error.response?.data?.error || error.message}`,
 | 
			
		||||
			);
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Delete image mutation
 | 
			
		||||
	const deleteImageMutation = useMutation({
 | 
			
		||||
		mutationFn: async (imageId) => {
 | 
			
		||||
			const response = await api.delete(`/docker/images/${imageId}`);
 | 
			
		||||
			return response.data;
 | 
			
		||||
		},
 | 
			
		||||
		onSuccess: () => {
 | 
			
		||||
			queryClient.invalidateQueries(["docker", "images"]);
 | 
			
		||||
			queryClient.invalidateQueries(["docker", "dashboard"]);
 | 
			
		||||
			setDeleteImageModal(null);
 | 
			
		||||
		},
 | 
			
		||||
		onError: (error) => {
 | 
			
		||||
			alert(
 | 
			
		||||
				`Failed to delete image: ${error.response?.data?.error || error.message}`,
 | 
			
		||||
			);
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Filter and sort containers
 | 
			
		||||
	const filteredContainers = useMemo(() => {
 | 
			
		||||
		if (!containersData?.containers) return [];
 | 
			
		||||
@@ -288,32 +336,36 @@ const Docker = () => {
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="space-y-6">
 | 
			
		||||
		<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
 | 
			
		||||
			{/* Header */}
 | 
			
		||||
			<div className="flex justify-between items-center">
 | 
			
		||||
			<div className="flex items-center justify-between mb-6">
 | 
			
		||||
				<div>
 | 
			
		||||
					<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
 | 
			
		||||
					<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
 | 
			
		||||
						Docker Inventory
 | 
			
		||||
					</h1>
 | 
			
		||||
					<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
 | 
			
		||||
					<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
 | 
			
		||||
						Monitor containers, images, and updates across your infrastructure
 | 
			
		||||
					</p>
 | 
			
		||||
				</div>
 | 
			
		||||
				<button
 | 
			
		||||
					type="button"
 | 
			
		||||
					onClick={() => {
 | 
			
		||||
						// Trigger refresh of all queries
 | 
			
		||||
						window.location.reload();
 | 
			
		||||
					}}
 | 
			
		||||
					className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
 | 
			
		||||
				>
 | 
			
		||||
					<RefreshCw className="h-4 w-4 mr-2" />
 | 
			
		||||
					Refresh
 | 
			
		||||
				</button>
 | 
			
		||||
				<div className="flex items-center gap-3">
 | 
			
		||||
					<button
 | 
			
		||||
						type="button"
 | 
			
		||||
						onClick={() => {
 | 
			
		||||
							// Trigger refresh based on active tab
 | 
			
		||||
							if (activeTab === "containers") refetchContainers();
 | 
			
		||||
							else if (activeTab === "images") refetchImages();
 | 
			
		||||
							else window.location.reload();
 | 
			
		||||
						}}
 | 
			
		||||
						className="btn-outline flex items-center justify-center p-2"
 | 
			
		||||
						title="Refresh data"
 | 
			
		||||
					>
 | 
			
		||||
						<RefreshCw className="h-4 w-4" />
 | 
			
		||||
					</button>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			{/* Dashboard Cards */}
 | 
			
		||||
			<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
 | 
			
		||||
			{/* Stats Summary */}
 | 
			
		||||
			<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
 | 
			
		||||
				<div className="card p-4">
 | 
			
		||||
					<div className="flex items-center">
 | 
			
		||||
						<div className="flex-shrink-0">
 | 
			
		||||
@@ -400,11 +452,11 @@ const Docker = () => {
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			{/* Tabs and Content */}
 | 
			
		||||
			<div className="card">
 | 
			
		||||
			{/* Docker List */}
 | 
			
		||||
			<div className="card flex-1 flex flex-col overflow-hidden min-h-0">
 | 
			
		||||
				{/* Tab Navigation */}
 | 
			
		||||
				<div className="border-b border-secondary-200 dark:border-secondary-700">
 | 
			
		||||
					<nav className="-mb-px flex space-x-8 px-6" aria-label="Tabs">
 | 
			
		||||
				<div className="border-b border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
					<nav className="-mb-px flex space-x-8 px-4" aria-label="Tabs">
 | 
			
		||||
						{[
 | 
			
		||||
							{ id: "containers", label: "Containers", icon: Container },
 | 
			
		||||
							{ id: "images", label: "Images", icon: Package },
 | 
			
		||||
@@ -443,7 +495,7 @@ const Docker = () => {
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				{/* Filters and Search */}
 | 
			
		||||
				<div className="p-6 border-b border-secondary-200 dark:border-secondary-700">
 | 
			
		||||
				<div className="p-4 border-b border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
					<div className="flex flex-col sm:flex-row gap-4">
 | 
			
		||||
						<div className="flex-1">
 | 
			
		||||
							<div className="relative">
 | 
			
		||||
@@ -498,7 +550,7 @@ const Docker = () => {
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				{/* Tab Content */}
 | 
			
		||||
				<div className="p-6">
 | 
			
		||||
				<div className="p-4 flex-1 overflow-auto">
 | 
			
		||||
					{/* Containers Tab */}
 | 
			
		||||
					{activeTab === "containers" && (
 | 
			
		||||
						<div className="overflow-x-auto">
 | 
			
		||||
@@ -522,83 +574,80 @@ const Docker = () => {
 | 
			
		||||
									</p>
 | 
			
		||||
								</div>
 | 
			
		||||
							) : (
 | 
			
		||||
								<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
 | 
			
		||||
									<thead className="bg-secondary-50 dark:bg-secondary-900">
 | 
			
		||||
								<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
 | 
			
		||||
									<thead className="bg-secondary-50 dark:bg-secondary-700">
 | 
			
		||||
										<tr>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
 | 
			
		||||
												onClick={() => handleSort("name")}
 | 
			
		||||
											>
 | 
			
		||||
												<div className="flex items-center gap-2">
 | 
			
		||||
											<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												<button
 | 
			
		||||
													type="button"
 | 
			
		||||
													onClick={() => handleSort("name")}
 | 
			
		||||
													className="flex items-center gap-2 hover:text-secondary-700"
 | 
			
		||||
												>
 | 
			
		||||
													Container Name
 | 
			
		||||
													{getSortIcon("name")}
 | 
			
		||||
												</div>
 | 
			
		||||
												</button>
 | 
			
		||||
											</th>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
 | 
			
		||||
												onClick={() => handleSort("image")}
 | 
			
		||||
											>
 | 
			
		||||
												<div className="flex items-center gap-2">
 | 
			
		||||
											<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												<button
 | 
			
		||||
													type="button"
 | 
			
		||||
													onClick={() => handleSort("image")}
 | 
			
		||||
													className="flex items-center gap-2 hover:text-secondary-700"
 | 
			
		||||
												>
 | 
			
		||||
													Image
 | 
			
		||||
													{getSortIcon("image")}
 | 
			
		||||
												</div>
 | 
			
		||||
												</button>
 | 
			
		||||
											</th>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
 | 
			
		||||
												onClick={() => handleSort("status")}
 | 
			
		||||
											>
 | 
			
		||||
												<div className="flex items-center gap-2">
 | 
			
		||||
											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												<button
 | 
			
		||||
													type="button"
 | 
			
		||||
													onClick={() => handleSort("status")}
 | 
			
		||||
													className="flex items-center gap-2 hover:text-secondary-700"
 | 
			
		||||
												>
 | 
			
		||||
													Status
 | 
			
		||||
													{getSortIcon("status")}
 | 
			
		||||
												</div>
 | 
			
		||||
												</button>
 | 
			
		||||
											</th>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
 | 
			
		||||
												onClick={() => handleSort("host")}
 | 
			
		||||
											>
 | 
			
		||||
												<div className="flex items-center gap-2">
 | 
			
		||||
											<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												<button
 | 
			
		||||
													type="button"
 | 
			
		||||
													onClick={() => handleSort("host")}
 | 
			
		||||
													className="flex items-center gap-2 hover:text-secondary-700"
 | 
			
		||||
												>
 | 
			
		||||
													Host
 | 
			
		||||
													{getSortIcon("host")}
 | 
			
		||||
												</div>
 | 
			
		||||
												</button>
 | 
			
		||||
											</th>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
 | 
			
		||||
											>
 | 
			
		||||
											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												Actions
 | 
			
		||||
											</th>
 | 
			
		||||
										</tr>
 | 
			
		||||
									</thead>
 | 
			
		||||
									<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700">
 | 
			
		||||
									<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
 | 
			
		||||
										{filteredContainers.map((container) => (
 | 
			
		||||
											<tr
 | 
			
		||||
												key={container.id}
 | 
			
		||||
												className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
 | 
			
		||||
												className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
 | 
			
		||||
											>
 | 
			
		||||
												<td className="px-6 py-4 whitespace-nowrap">
 | 
			
		||||
													<div className="flex items-center">
 | 
			
		||||
														<Container className="h-5 w-5 text-secondary-400 mr-3" />
 | 
			
		||||
												<td className="px-4 py-2 whitespace-nowrap">
 | 
			
		||||
													<div className="flex items-center gap-2">
 | 
			
		||||
														<Container className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
 | 
			
		||||
														<Link
 | 
			
		||||
															to={`/docker/containers/${container.id}`}
 | 
			
		||||
															className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
 | 
			
		||||
															className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate"
 | 
			
		||||
														>
 | 
			
		||||
															{container.name}
 | 
			
		||||
														</Link>
 | 
			
		||||
													</div>
 | 
			
		||||
												</td>
 | 
			
		||||
												<td className="px-6 py-4">
 | 
			
		||||
												<td className="px-4 py-2">
 | 
			
		||||
													<div className="text-sm text-secondary-900 dark:text-white">
 | 
			
		||||
														{container.image_name}:{container.image_tag}
 | 
			
		||||
													</div>
 | 
			
		||||
												</td>
 | 
			
		||||
												<td className="px-6 py-4 whitespace-nowrap">
 | 
			
		||||
												<td className="px-4 py-2 whitespace-nowrap text-center">
 | 
			
		||||
													{getStatusBadge(container.status)}
 | 
			
		||||
												</td>
 | 
			
		||||
												<td className="px-6 py-4 whitespace-nowrap">
 | 
			
		||||
												<td className="px-4 py-2 whitespace-nowrap">
 | 
			
		||||
													<Link
 | 
			
		||||
														to={`/hosts/${container.host_id}`}
 | 
			
		||||
														className="text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
 | 
			
		||||
@@ -608,14 +657,24 @@ const Docker = () => {
 | 
			
		||||
															"Unknown"}
 | 
			
		||||
													</Link>
 | 
			
		||||
												</td>
 | 
			
		||||
												<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
 | 
			
		||||
													<Link
 | 
			
		||||
														to={`/docker/containers/${container.id}`}
 | 
			
		||||
														className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
 | 
			
		||||
													>
 | 
			
		||||
														View
 | 
			
		||||
														<ExternalLink className="ml-1 h-4 w-4" />
 | 
			
		||||
													</Link>
 | 
			
		||||
												<td className="px-4 py-2 whitespace-nowrap text-center">
 | 
			
		||||
													<div className="flex items-center justify-center gap-3">
 | 
			
		||||
														<Link
 | 
			
		||||
															to={`/docker/containers/${container.id}`}
 | 
			
		||||
															className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center gap-1"
 | 
			
		||||
															title="View details"
 | 
			
		||||
														>
 | 
			
		||||
															<ExternalLink className="h-4 w-4" />
 | 
			
		||||
														</Link>
 | 
			
		||||
														<button
 | 
			
		||||
															type="button"
 | 
			
		||||
															onClick={() => setDeleteContainerModal(container)}
 | 
			
		||||
															className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 inline-flex items-center"
 | 
			
		||||
															title="Delete container from inventory"
 | 
			
		||||
														>
 | 
			
		||||
															<Trash2 className="h-4 w-4" />
 | 
			
		||||
														</button>
 | 
			
		||||
													</div>
 | 
			
		||||
												</td>
 | 
			
		||||
											</tr>
 | 
			
		||||
										))}
 | 
			
		||||
@@ -648,88 +707,79 @@ const Docker = () => {
 | 
			
		||||
									</p>
 | 
			
		||||
								</div>
 | 
			
		||||
							) : (
 | 
			
		||||
								<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
 | 
			
		||||
									<thead className="bg-secondary-50 dark:bg-secondary-900">
 | 
			
		||||
								<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
 | 
			
		||||
									<thead className="bg-secondary-50 dark:bg-secondary-700">
 | 
			
		||||
										<tr>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
 | 
			
		||||
												onClick={() => handleSort("repository")}
 | 
			
		||||
											>
 | 
			
		||||
												<div className="flex items-center gap-2">
 | 
			
		||||
											<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												<button
 | 
			
		||||
													type="button"
 | 
			
		||||
													onClick={() => handleSort("repository")}
 | 
			
		||||
													className="flex items-center gap-2 hover:text-secondary-700"
 | 
			
		||||
												>
 | 
			
		||||
													Repository
 | 
			
		||||
													{getSortIcon("repository")}
 | 
			
		||||
												</div>
 | 
			
		||||
												</button>
 | 
			
		||||
											</th>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
 | 
			
		||||
												onClick={() => handleSort("tag")}
 | 
			
		||||
											>
 | 
			
		||||
												<div className="flex items-center gap-2">
 | 
			
		||||
											<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												<button
 | 
			
		||||
													type="button"
 | 
			
		||||
													onClick={() => handleSort("tag")}
 | 
			
		||||
													className="flex items-center gap-2 hover:text-secondary-700"
 | 
			
		||||
												>
 | 
			
		||||
													Tag
 | 
			
		||||
													{getSortIcon("tag")}
 | 
			
		||||
												</div>
 | 
			
		||||
												</button>
 | 
			
		||||
											</th>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
 | 
			
		||||
											>
 | 
			
		||||
											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												Source
 | 
			
		||||
											</th>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
 | 
			
		||||
												onClick={() => handleSort("containers")}
 | 
			
		||||
											>
 | 
			
		||||
												<div className="flex items-center gap-2">
 | 
			
		||||
											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												<button
 | 
			
		||||
													type="button"
 | 
			
		||||
													onClick={() => handleSort("containers")}
 | 
			
		||||
													className="flex items-center gap-2 hover:text-secondary-700"
 | 
			
		||||
												>
 | 
			
		||||
													Containers
 | 
			
		||||
													{getSortIcon("containers")}
 | 
			
		||||
												</div>
 | 
			
		||||
												</button>
 | 
			
		||||
											</th>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
 | 
			
		||||
											>
 | 
			
		||||
											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												Updates
 | 
			
		||||
											</th>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
 | 
			
		||||
											>
 | 
			
		||||
											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												Actions
 | 
			
		||||
											</th>
 | 
			
		||||
										</tr>
 | 
			
		||||
									</thead>
 | 
			
		||||
									<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700">
 | 
			
		||||
									<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
 | 
			
		||||
										{filteredImages.map((image) => (
 | 
			
		||||
											<tr
 | 
			
		||||
												key={image.id}
 | 
			
		||||
												className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
 | 
			
		||||
												className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
 | 
			
		||||
											>
 | 
			
		||||
												<td className="px-6 py-4 whitespace-nowrap">
 | 
			
		||||
													<div className="flex items-center">
 | 
			
		||||
														<Package className="h-5 w-5 text-secondary-400 mr-3" />
 | 
			
		||||
												<td className="px-4 py-2 whitespace-nowrap">
 | 
			
		||||
													<div className="flex items-center gap-2">
 | 
			
		||||
														<Package className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
 | 
			
		||||
														<Link
 | 
			
		||||
															to={`/docker/images/${image.id}`}
 | 
			
		||||
															className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
 | 
			
		||||
															className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate"
 | 
			
		||||
														>
 | 
			
		||||
															{image.repository}
 | 
			
		||||
														</Link>
 | 
			
		||||
													</div>
 | 
			
		||||
												</td>
 | 
			
		||||
												<td className="px-6 py-4 whitespace-nowrap">
 | 
			
		||||
												<td className="px-4 py-2 whitespace-nowrap">
 | 
			
		||||
													<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
 | 
			
		||||
														{image.tag}
 | 
			
		||||
													</span>
 | 
			
		||||
												</td>
 | 
			
		||||
												<td className="px-6 py-4 whitespace-nowrap">
 | 
			
		||||
												<td className="px-4 py-2 whitespace-nowrap text-center">
 | 
			
		||||
													{getSourceBadge(image.source)}
 | 
			
		||||
												</td>
 | 
			
		||||
												<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
 | 
			
		||||
												<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-secondary-900 dark:text-white">
 | 
			
		||||
													{image._count?.docker_containers || 0}
 | 
			
		||||
												</td>
 | 
			
		||||
												<td className="px-6 py-4 whitespace-nowrap">
 | 
			
		||||
												<td className="px-4 py-2 whitespace-nowrap text-center">
 | 
			
		||||
													{image.hasUpdates ? (
 | 
			
		||||
														<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
 | 
			
		||||
															<AlertTriangle className="h-3 w-3 mr-1" />
 | 
			
		||||
@@ -741,14 +791,24 @@ const Docker = () => {
 | 
			
		||||
														</span>
 | 
			
		||||
													)}
 | 
			
		||||
												</td>
 | 
			
		||||
												<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
 | 
			
		||||
													<Link
 | 
			
		||||
														to={`/docker/images/${image.id}`}
 | 
			
		||||
														className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
 | 
			
		||||
													>
 | 
			
		||||
														View
 | 
			
		||||
														<ExternalLink className="ml-1 h-4 w-4" />
 | 
			
		||||
													</Link>
 | 
			
		||||
												<td className="px-4 py-2 whitespace-nowrap text-center">
 | 
			
		||||
													<div className="flex items-center justify-center gap-3">
 | 
			
		||||
														<Link
 | 
			
		||||
															to={`/docker/images/${image.id}`}
 | 
			
		||||
															className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
 | 
			
		||||
															title="View details"
 | 
			
		||||
														>
 | 
			
		||||
															<ExternalLink className="h-4 w-4" />
 | 
			
		||||
														</Link>
 | 
			
		||||
														<button
 | 
			
		||||
															type="button"
 | 
			
		||||
															onClick={() => setDeleteImageModal(image)}
 | 
			
		||||
															className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 inline-flex items-center"
 | 
			
		||||
															title="Delete image from inventory"
 | 
			
		||||
														>
 | 
			
		||||
															<Trash2 className="h-4 w-4" />
 | 
			
		||||
														</button>
 | 
			
		||||
													</div>
 | 
			
		||||
												</td>
 | 
			
		||||
											</tr>
 | 
			
		||||
										))}
 | 
			
		||||
@@ -781,86 +841,80 @@ const Docker = () => {
 | 
			
		||||
									</p>
 | 
			
		||||
								</div>
 | 
			
		||||
							) : (
 | 
			
		||||
								<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
 | 
			
		||||
									<thead className="bg-secondary-50 dark:bg-secondary-900">
 | 
			
		||||
								<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
 | 
			
		||||
									<thead className="bg-secondary-50 dark:bg-secondary-700">
 | 
			
		||||
										<tr>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
 | 
			
		||||
												onClick={() => handleSort("name")}
 | 
			
		||||
											>
 | 
			
		||||
												<div className="flex items-center gap-2">
 | 
			
		||||
											<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												<button
 | 
			
		||||
													type="button"
 | 
			
		||||
													onClick={() => handleSort("name")}
 | 
			
		||||
													className="flex items-center gap-2 hover:text-secondary-700"
 | 
			
		||||
												>
 | 
			
		||||
													Host Name
 | 
			
		||||
													{getSortIcon("name")}
 | 
			
		||||
												</div>
 | 
			
		||||
												</button>
 | 
			
		||||
											</th>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
 | 
			
		||||
												onClick={() => handleSort("containers")}
 | 
			
		||||
											>
 | 
			
		||||
												<div className="flex items-center gap-2">
 | 
			
		||||
											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												<button
 | 
			
		||||
													type="button"
 | 
			
		||||
													onClick={() => handleSort("containers")}
 | 
			
		||||
													className="flex items-center gap-2 hover:text-secondary-700"
 | 
			
		||||
												>
 | 
			
		||||
													Containers
 | 
			
		||||
													{getSortIcon("containers")}
 | 
			
		||||
												</div>
 | 
			
		||||
												</button>
 | 
			
		||||
											</th>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
 | 
			
		||||
											>
 | 
			
		||||
											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												Running
 | 
			
		||||
											</th>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
 | 
			
		||||
												onClick={() => handleSort("images")}
 | 
			
		||||
											>
 | 
			
		||||
												<div className="flex items-center gap-2">
 | 
			
		||||
											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												<button
 | 
			
		||||
													type="button"
 | 
			
		||||
													onClick={() => handleSort("images")}
 | 
			
		||||
													className="flex items-center gap-2 hover:text-secondary-700"
 | 
			
		||||
												>
 | 
			
		||||
													Images
 | 
			
		||||
													{getSortIcon("images")}
 | 
			
		||||
												</div>
 | 
			
		||||
												</button>
 | 
			
		||||
											</th>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
 | 
			
		||||
											>
 | 
			
		||||
											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												Actions
 | 
			
		||||
											</th>
 | 
			
		||||
										</tr>
 | 
			
		||||
									</thead>
 | 
			
		||||
									<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700">
 | 
			
		||||
									<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
 | 
			
		||||
										{filteredHosts.map((host) => (
 | 
			
		||||
											<tr
 | 
			
		||||
												key={host.id}
 | 
			
		||||
												className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
 | 
			
		||||
												className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
 | 
			
		||||
											>
 | 
			
		||||
												<td className="px-6 py-4 whitespace-nowrap">
 | 
			
		||||
													<div className="flex items-center">
 | 
			
		||||
														<Server className="h-5 w-5 text-secondary-400 mr-3" />
 | 
			
		||||
												<td className="px-4 py-2 whitespace-nowrap">
 | 
			
		||||
													<div className="flex items-center gap-2">
 | 
			
		||||
														<Server className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
 | 
			
		||||
														<Link
 | 
			
		||||
															to={`/docker/hosts/${host.id}`}
 | 
			
		||||
															className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
 | 
			
		||||
															className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate"
 | 
			
		||||
														>
 | 
			
		||||
															{host.friendly_name || host.hostname}
 | 
			
		||||
														</Link>
 | 
			
		||||
													</div>
 | 
			
		||||
												</td>
 | 
			
		||||
												<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
 | 
			
		||||
												<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-secondary-900 dark:text-white">
 | 
			
		||||
													{host.dockerStats?.totalContainers || 0}
 | 
			
		||||
												</td>
 | 
			
		||||
												<td className="px-6 py-4 whitespace-nowrap text-sm text-green-600 dark:text-green-400">
 | 
			
		||||
												<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-green-600 dark:text-green-400 font-medium">
 | 
			
		||||
													{host.dockerStats?.runningContainers || 0}
 | 
			
		||||
												</td>
 | 
			
		||||
												<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
 | 
			
		||||
												<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-secondary-900 dark:text-white">
 | 
			
		||||
													{host.dockerStats?.totalImages || 0}
 | 
			
		||||
												</td>
 | 
			
		||||
												<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
 | 
			
		||||
												<td className="px-4 py-2 whitespace-nowrap text-center">
 | 
			
		||||
													<Link
 | 
			
		||||
														to={`/docker/hosts/${host.id}`}
 | 
			
		||||
														className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
 | 
			
		||||
														className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center gap-1"
 | 
			
		||||
														title="View details"
 | 
			
		||||
													>
 | 
			
		||||
														View
 | 
			
		||||
														<ExternalLink className="ml-1 h-4 w-4" />
 | 
			
		||||
														<ExternalLink className="h-4 w-4" />
 | 
			
		||||
													</Link>
 | 
			
		||||
												</td>
 | 
			
		||||
											</tr>
 | 
			
		||||
@@ -892,82 +946,64 @@ const Docker = () => {
 | 
			
		||||
									</p>
 | 
			
		||||
								</div>
 | 
			
		||||
							) : (
 | 
			
		||||
								<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
 | 
			
		||||
									<thead className="bg-secondary-50 dark:bg-secondary-900">
 | 
			
		||||
								<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
 | 
			
		||||
									<thead className="bg-secondary-50 dark:bg-secondary-700">
 | 
			
		||||
										<tr>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
 | 
			
		||||
											>
 | 
			
		||||
											<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												Image
 | 
			
		||||
											</th>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
 | 
			
		||||
											>
 | 
			
		||||
											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												Tag
 | 
			
		||||
											</th>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
 | 
			
		||||
											>
 | 
			
		||||
											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												Detection Method
 | 
			
		||||
											</th>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
 | 
			
		||||
											>
 | 
			
		||||
											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												Status
 | 
			
		||||
											</th>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
 | 
			
		||||
											>
 | 
			
		||||
											<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												Affected
 | 
			
		||||
											</th>
 | 
			
		||||
											<th
 | 
			
		||||
												scope="col"
 | 
			
		||||
												className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
 | 
			
		||||
											>
 | 
			
		||||
											<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
												Actions
 | 
			
		||||
											</th>
 | 
			
		||||
										</tr>
 | 
			
		||||
									</thead>
 | 
			
		||||
									<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700">
 | 
			
		||||
									<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
 | 
			
		||||
										{updatesData.updates.map((update) => (
 | 
			
		||||
											<tr
 | 
			
		||||
												key={update.id}
 | 
			
		||||
												className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
 | 
			
		||||
												className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
 | 
			
		||||
											>
 | 
			
		||||
												<td className="px-6 py-4 whitespace-nowrap">
 | 
			
		||||
													<div className="flex items-center">
 | 
			
		||||
														<Package className="h-5 w-5 text-secondary-400 mr-3" />
 | 
			
		||||
												<td className="px-4 py-2 whitespace-nowrap">
 | 
			
		||||
													<div className="flex items-center gap-2">
 | 
			
		||||
														<Package className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
 | 
			
		||||
														<Link
 | 
			
		||||
															to={`/docker/images/${update.image_id}`}
 | 
			
		||||
															className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
 | 
			
		||||
															className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate"
 | 
			
		||||
														>
 | 
			
		||||
															{update.docker_images?.repository}
 | 
			
		||||
														</Link>
 | 
			
		||||
													</div>
 | 
			
		||||
												</td>
 | 
			
		||||
												<td className="px-6 py-4 whitespace-nowrap">
 | 
			
		||||
												<td className="px-4 py-2 whitespace-nowrap text-center">
 | 
			
		||||
													<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
 | 
			
		||||
														{update.current_tag}
 | 
			
		||||
													</span>
 | 
			
		||||
												</td>
 | 
			
		||||
												<td className="px-6 py-4 whitespace-nowrap">
 | 
			
		||||
												<td className="px-4 py-2 whitespace-nowrap text-center">
 | 
			
		||||
													<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
 | 
			
		||||
														<Package className="h-3 w-3 mr-1" />
 | 
			
		||||
														Digest Comparison
 | 
			
		||||
														Digest
 | 
			
		||||
													</span>
 | 
			
		||||
												</td>
 | 
			
		||||
												<td className="px-6 py-4 whitespace-nowrap">
 | 
			
		||||
												<td className="px-4 py-2 whitespace-nowrap text-center">
 | 
			
		||||
													<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
 | 
			
		||||
														<AlertTriangle className="h-3 w-3 mr-1" />
 | 
			
		||||
														Update Available
 | 
			
		||||
														Available
 | 
			
		||||
													</span>
 | 
			
		||||
												</td>
 | 
			
		||||
												<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
 | 
			
		||||
												<td className="px-4 py-2 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
 | 
			
		||||
													{update.affectedContainersCount} container
 | 
			
		||||
													{update.affectedContainersCount !== 1 ? "s" : ""}
 | 
			
		||||
													{update.affectedHosts?.length > 0 && (
 | 
			
		||||
@@ -978,13 +1014,13 @@ const Docker = () => {
 | 
			
		||||
														</span>
 | 
			
		||||
													)}
 | 
			
		||||
												</td>
 | 
			
		||||
												<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
 | 
			
		||||
												<td className="px-4 py-2 whitespace-nowrap text-center">
 | 
			
		||||
													<Link
 | 
			
		||||
														to={`/docker/images/${update.image_id}`}
 | 
			
		||||
														className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
 | 
			
		||||
														className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center gap-1"
 | 
			
		||||
														title="View details"
 | 
			
		||||
													>
 | 
			
		||||
														View
 | 
			
		||||
														<ExternalLink className="ml-1 h-4 w-4" />
 | 
			
		||||
														<ExternalLink className="h-4 w-4" />
 | 
			
		||||
													</Link>
 | 
			
		||||
												</td>
 | 
			
		||||
											</tr>
 | 
			
		||||
@@ -996,6 +1032,141 @@ const Docker = () => {
 | 
			
		||||
					)}
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			{/* Delete Container Modal */}
 | 
			
		||||
			{deleteContainerModal && (
 | 
			
		||||
				<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
 | 
			
		||||
					<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 max-w-md w-full mx-4">
 | 
			
		||||
						<div className="flex items-start mb-4">
 | 
			
		||||
							<div className="flex-shrink-0">
 | 
			
		||||
								<AlertTriangle className="h-6 w-6 text-red-600" />
 | 
			
		||||
							</div>
 | 
			
		||||
							<div className="ml-3 flex-1">
 | 
			
		||||
								<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
 | 
			
		||||
									Delete Container
 | 
			
		||||
								</h3>
 | 
			
		||||
								<div className="mt-2 text-sm text-secondary-600 dark:text-secondary-300">
 | 
			
		||||
									<p className="mb-2">
 | 
			
		||||
										Are you sure you want to delete this container from the
 | 
			
		||||
										inventory?
 | 
			
		||||
									</p>
 | 
			
		||||
									<div className="bg-secondary-100 dark:bg-secondary-700 p-3 rounded-md">
 | 
			
		||||
										<p className="font-medium text-secondary-900 dark:text-white">
 | 
			
		||||
											{deleteContainerModal.name}
 | 
			
		||||
										</p>
 | 
			
		||||
										<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-1">
 | 
			
		||||
											Image: {deleteContainerModal.image_name}:
 | 
			
		||||
											{deleteContainerModal.image_tag}
 | 
			
		||||
										</p>
 | 
			
		||||
										<p className="text-xs text-secondary-600 dark:text-secondary-400">
 | 
			
		||||
											Host:{" "}
 | 
			
		||||
											{deleteContainerModal.host?.friendly_name || "Unknown"}
 | 
			
		||||
										</p>
 | 
			
		||||
									</div>
 | 
			
		||||
									<p className="mt-3 text-red-600 dark:text-red-400 font-medium">
 | 
			
		||||
										⚠️ This only removes the container from PatchMon's inventory.
 | 
			
		||||
										It does NOT stop or delete the actual Docker container on
 | 
			
		||||
										the host.
 | 
			
		||||
									</p>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
						<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-3">
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={() =>
 | 
			
		||||
									deleteContainerMutation.mutate(deleteContainerModal.id)
 | 
			
		||||
								}
 | 
			
		||||
								disabled={deleteContainerMutation.isPending}
 | 
			
		||||
								className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
 | 
			
		||||
							>
 | 
			
		||||
								{deleteContainerMutation.isPending
 | 
			
		||||
									? "Deleting..."
 | 
			
		||||
									: "Delete from Inventory"}
 | 
			
		||||
							</button>
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={() => setDeleteContainerModal(null)}
 | 
			
		||||
								disabled={deleteContainerMutation.isPending}
 | 
			
		||||
								className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-700 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
 | 
			
		||||
							>
 | 
			
		||||
								Cancel
 | 
			
		||||
							</button>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			)}
 | 
			
		||||
 | 
			
		||||
			{/* Delete Image Modal */}
 | 
			
		||||
			{deleteImageModal && (
 | 
			
		||||
				<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
 | 
			
		||||
					<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 max-w-md w-full mx-4">
 | 
			
		||||
						<div className="flex items-start mb-4">
 | 
			
		||||
							<div className="flex-shrink-0">
 | 
			
		||||
								<AlertTriangle className="h-6 w-6 text-red-600" />
 | 
			
		||||
							</div>
 | 
			
		||||
							<div className="ml-3 flex-1">
 | 
			
		||||
								<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
 | 
			
		||||
									Delete Image
 | 
			
		||||
								</h3>
 | 
			
		||||
								<div className="mt-2 text-sm text-secondary-600 dark:text-secondary-300">
 | 
			
		||||
									<p className="mb-2">
 | 
			
		||||
										Are you sure you want to delete this image from the
 | 
			
		||||
										inventory?
 | 
			
		||||
									</p>
 | 
			
		||||
									<div className="bg-secondary-100 dark:bg-secondary-700 p-3 rounded-md">
 | 
			
		||||
										<p className="font-medium text-secondary-900 dark:text-white">
 | 
			
		||||
											{deleteImageModal.repository}:{deleteImageModal.tag}
 | 
			
		||||
										</p>
 | 
			
		||||
										<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-1">
 | 
			
		||||
											Source: {deleteImageModal.source}
 | 
			
		||||
										</p>
 | 
			
		||||
										<p className="text-xs text-secondary-600 dark:text-secondary-400">
 | 
			
		||||
											Containers using this:{" "}
 | 
			
		||||
											{deleteImageModal._count?.docker_containers || 0}
 | 
			
		||||
										</p>
 | 
			
		||||
									</div>
 | 
			
		||||
									{deleteImageModal._count?.docker_containers > 0 ? (
 | 
			
		||||
										<p className="mt-3 text-red-600 dark:text-red-400 font-medium">
 | 
			
		||||
											⚠️ Cannot delete: This image is in use by{" "}
 | 
			
		||||
											{deleteImageModal._count.docker_containers} container(s).
 | 
			
		||||
											Delete the containers first.
 | 
			
		||||
										</p>
 | 
			
		||||
									) : (
 | 
			
		||||
										<p className="mt-3 text-red-600 dark:text-red-400 font-medium">
 | 
			
		||||
											⚠️ This only removes the image from PatchMon's inventory.
 | 
			
		||||
											It does NOT delete the actual Docker image from hosts.
 | 
			
		||||
										</p>
 | 
			
		||||
									)}
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
						<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-3">
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={() => deleteImageMutation.mutate(deleteImageModal.id)}
 | 
			
		||||
								disabled={
 | 
			
		||||
									deleteImageMutation.isPending ||
 | 
			
		||||
									deleteImageModal._count?.docker_containers > 0
 | 
			
		||||
								}
 | 
			
		||||
								className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
 | 
			
		||||
							>
 | 
			
		||||
								{deleteImageMutation.isPending
 | 
			
		||||
									? "Deleting..."
 | 
			
		||||
									: "Delete from Inventory"}
 | 
			
		||||
							</button>
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={() => setDeleteImageModal(null)}
 | 
			
		||||
								disabled={deleteImageMutation.isPending}
 | 
			
		||||
								className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-700 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
 | 
			
		||||
							>
 | 
			
		||||
								Cancel
 | 
			
		||||
							</button>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			)}
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -402,105 +402,71 @@ const Hosts = () => {
 | 
			
		||||
		const token = localStorage.getItem("token");
 | 
			
		||||
		if (!token) return;
 | 
			
		||||
 | 
			
		||||
		// Fetch initial WebSocket status for all hosts
 | 
			
		||||
		// Fetch initial WebSocket status for all hosts
 | 
			
		||||
		const fetchInitialStatus = async () => {
 | 
			
		||||
			const statusPromises = hosts
 | 
			
		||||
			const apiIds = hosts
 | 
			
		||||
				.filter((host) => host.api_id)
 | 
			
		||||
				.map(async (host) => {
 | 
			
		||||
					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 },
 | 
			
		||||
					};
 | 
			
		||||
				});
 | 
			
		||||
				.map((host) => host.api_id);
 | 
			
		||||
 | 
			
		||||
			const results = await Promise.all(statusPromises);
 | 
			
		||||
			const initialStatusMap = {};
 | 
			
		||||
			results.forEach(({ apiId, status }) => {
 | 
			
		||||
				initialStatusMap[apiId] = status;
 | 
			
		||||
			});
 | 
			
		||||
			if (apiIds.length === 0) return;
 | 
			
		||||
 | 
			
		||||
			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();
 | 
			
		||||
	}, [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(() => {
 | 
			
		||||
		if (!hosts || hosts.length === 0) return;
 | 
			
		||||
 | 
			
		||||
		const token = localStorage.getItem("token");
 | 
			
		||||
		if (!token) return;
 | 
			
		||||
 | 
			
		||||
		const eventSources = new Map();
 | 
			
		||||
		let isMounted = true;
 | 
			
		||||
		// Use polling instead of SSE to avoid connection pool issues
 | 
			
		||||
		// 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 (!isMounted || eventSources.has(apiId)) return;
 | 
			
		||||
			if (apiIds.length === 0) return;
 | 
			
		||||
 | 
			
		||||
			try {
 | 
			
		||||
				const es = new EventSource(
 | 
			
		||||
					`/api/v1/ws/status/${apiId}/stream?token=${encodeURIComponent(token)}`,
 | 
			
		||||
				);
 | 
			
		||||
 | 
			
		||||
				es.onmessage = (event) => {
 | 
			
		||||
					try {
 | 
			
		||||
						const data = JSON.parse(event.data);
 | 
			
		||||
						if (isMounted) {
 | 
			
		||||
							setWsStatusMap((prev) => {
 | 
			
		||||
								const newMap = { ...prev, [apiId]: data };
 | 
			
		||||
								return newMap;
 | 
			
		||||
							});
 | 
			
		||||
						}
 | 
			
		||||
					} catch (_err) {
 | 
			
		||||
						// Silently handle parse errors
 | 
			
		||||
			fetch(`/api/v1/ws/status?apiIds=${apiIds.join(",")}`, {
 | 
			
		||||
				headers: {
 | 
			
		||||
					Authorization: `Bearer ${token}`,
 | 
			
		||||
				},
 | 
			
		||||
			})
 | 
			
		||||
				.then((response) => response.json())
 | 
			
		||||
				.then((result) => {
 | 
			
		||||
					if (result.success && result.data) {
 | 
			
		||||
						setWsStatusMap(result.data);
 | 
			
		||||
					}
 | 
			
		||||
				};
 | 
			
		||||
 | 
			
		||||
				es.onerror = (_error) => {
 | 
			
		||||
					console.log(`[SSE] Connection error for ${apiId}, retrying...`);
 | 
			
		||||
					es?.close();
 | 
			
		||||
					eventSources.delete(apiId);
 | 
			
		||||
					if (isMounted) {
 | 
			
		||||
						// Retry connection after 5 seconds with exponential backoff
 | 
			
		||||
						setTimeout(() => connectHost(apiId), 5000);
 | 
			
		||||
					}
 | 
			
		||||
				};
 | 
			
		||||
 | 
			
		||||
				eventSources.set(apiId, es);
 | 
			
		||||
			} catch (_err) {
 | 
			
		||||
				// Silently handle connection errors
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		// Connect to all hosts
 | 
			
		||||
		for (const host of hosts) {
 | 
			
		||||
			if (host.api_id) {
 | 
			
		||||
				connectHost(host.api_id);
 | 
			
		||||
			} else {
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
				})
 | 
			
		||||
				.catch(() => {
 | 
			
		||||
					// Silently handle errors
 | 
			
		||||
				});
 | 
			
		||||
		}, 10000); // Poll every 10 seconds
 | 
			
		||||
 | 
			
		||||
		// Cleanup function
 | 
			
		||||
		return () => {
 | 
			
		||||
			isMounted = false;
 | 
			
		||||
			for (const es of eventSources.values()) {
 | 
			
		||||
				es.close();
 | 
			
		||||
			}
 | 
			
		||||
			eventSources.clear();
 | 
			
		||||
			clearInterval(pollInterval);
 | 
			
		||||
		};
 | 
			
		||||
	}, [hosts]);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										399
									
								
								frontend/src/pages/settings/SettingsMetrics.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										399
									
								
								frontend/src/pages/settings/SettingsMetrics.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,399 @@
 | 
			
		||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 | 
			
		||||
import {
 | 
			
		||||
	AlertCircle,
 | 
			
		||||
	BarChart3,
 | 
			
		||||
	CheckCircle,
 | 
			
		||||
	Eye,
 | 
			
		||||
	EyeOff,
 | 
			
		||||
	Globe,
 | 
			
		||||
	Info,
 | 
			
		||||
	RefreshCw,
 | 
			
		||||
	Send,
 | 
			
		||||
	Shield,
 | 
			
		||||
} from "lucide-react";
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import SettingsLayout from "../../components/SettingsLayout";
 | 
			
		||||
 | 
			
		||||
// API functions - will be added to utils/api.js
 | 
			
		||||
const metricsAPI = {
 | 
			
		||||
	getSettings: () =>
 | 
			
		||||
		fetch("/api/v1/metrics", {
 | 
			
		||||
			headers: {
 | 
			
		||||
				Authorization: `Bearer ${localStorage.getItem("token")}`,
 | 
			
		||||
			},
 | 
			
		||||
		}).then((res) => res.json()),
 | 
			
		||||
	updateSettings: (data) =>
 | 
			
		||||
		fetch("/api/v1/metrics", {
 | 
			
		||||
			method: "PUT",
 | 
			
		||||
			headers: {
 | 
			
		||||
				"Content-Type": "application/json",
 | 
			
		||||
				Authorization: `Bearer ${localStorage.getItem("token")}`,
 | 
			
		||||
			},
 | 
			
		||||
			body: JSON.stringify(data),
 | 
			
		||||
		}).then((res) => res.json()),
 | 
			
		||||
	regenerateId: () =>
 | 
			
		||||
		fetch("/api/v1/metrics/regenerate-id", {
 | 
			
		||||
			method: "POST",
 | 
			
		||||
			headers: {
 | 
			
		||||
				Authorization: `Bearer ${localStorage.getItem("token")}`,
 | 
			
		||||
			},
 | 
			
		||||
		}).then((res) => res.json()),
 | 
			
		||||
	sendNow: () =>
 | 
			
		||||
		fetch("/api/v1/metrics/send-now", {
 | 
			
		||||
			method: "POST",
 | 
			
		||||
			headers: {
 | 
			
		||||
				Authorization: `Bearer ${localStorage.getItem("token")}`,
 | 
			
		||||
			},
 | 
			
		||||
		}).then((res) => res.json()),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const SettingsMetrics = () => {
 | 
			
		||||
	const queryClient = useQueryClient();
 | 
			
		||||
	const [showFullId, setShowFullId] = useState(false);
 | 
			
		||||
 | 
			
		||||
	// Fetch metrics settings
 | 
			
		||||
	const {
 | 
			
		||||
		data: metricsSettings,
 | 
			
		||||
		isLoading,
 | 
			
		||||
		error,
 | 
			
		||||
	} = useQuery({
 | 
			
		||||
		queryKey: ["metrics-settings"],
 | 
			
		||||
		queryFn: () => metricsAPI.getSettings(),
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Toggle metrics mutation
 | 
			
		||||
	const toggleMetricsMutation = useMutation({
 | 
			
		||||
		mutationFn: (enabled) =>
 | 
			
		||||
			metricsAPI.updateSettings({ metrics_enabled: enabled }),
 | 
			
		||||
		onSuccess: () => {
 | 
			
		||||
			queryClient.invalidateQueries(["metrics-settings"]);
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Regenerate ID mutation
 | 
			
		||||
	const regenerateIdMutation = useMutation({
 | 
			
		||||
		mutationFn: () => metricsAPI.regenerateId(),
 | 
			
		||||
		onSuccess: () => {
 | 
			
		||||
			queryClient.invalidateQueries(["metrics-settings"]);
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Send now mutation
 | 
			
		||||
	const sendNowMutation = useMutation({
 | 
			
		||||
		mutationFn: () => metricsAPI.sendNow(),
 | 
			
		||||
		onSuccess: () => {
 | 
			
		||||
			queryClient.invalidateQueries(["metrics-settings"]);
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	if (isLoading) {
 | 
			
		||||
		return (
 | 
			
		||||
			<SettingsLayout>
 | 
			
		||||
				<div className="flex items-center justify-center h-64">
 | 
			
		||||
					<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</SettingsLayout>
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (error) {
 | 
			
		||||
		return (
 | 
			
		||||
			<SettingsLayout>
 | 
			
		||||
				<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
 | 
			
		||||
					<div className="flex">
 | 
			
		||||
						<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
 | 
			
		||||
						<div className="ml-3">
 | 
			
		||||
							<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
 | 
			
		||||
								Error loading metrics settings
 | 
			
		||||
							</h3>
 | 
			
		||||
							<p className="mt-1 text-sm text-red-700 dark:text-red-300">
 | 
			
		||||
								{error.message || "Failed to load settings"}
 | 
			
		||||
							</p>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</SettingsLayout>
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const maskId = (id) => {
 | 
			
		||||
		if (!id) return "";
 | 
			
		||||
		if (showFullId) return id;
 | 
			
		||||
		return `${id.substring(0, 8)}...${id.substring(id.length - 8)}`;
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<SettingsLayout>
 | 
			
		||||
			<div className="space-y-6">
 | 
			
		||||
				{/* Header */}
 | 
			
		||||
				<div className="flex items-center mb-6">
 | 
			
		||||
					<BarChart3 className="h-6 w-6 text-primary-600 mr-3" />
 | 
			
		||||
					<div>
 | 
			
		||||
						<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
			
		||||
							Anonymous Metrics & Telemetry
 | 
			
		||||
						</h2>
 | 
			
		||||
						<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
 | 
			
		||||
							Help us understand PatchMon's global usage (100% anonymous)
 | 
			
		||||
						</p>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				{/* Privacy Information */}
 | 
			
		||||
				<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg p-6">
 | 
			
		||||
					<div className="flex">
 | 
			
		||||
						<Shield className="h-6 w-6 text-blue-600 dark:text-blue-400 flex-shrink-0" />
 | 
			
		||||
						<div className="ml-4 flex-1">
 | 
			
		||||
							<h3 className="text-base font-semibold text-blue-900 dark:text-blue-100 mb-3">
 | 
			
		||||
								Your Privacy Matters
 | 
			
		||||
							</h3>
 | 
			
		||||
							<div className="text-sm text-blue-800 dark:text-blue-200 space-y-2">
 | 
			
		||||
								<p className="flex items-start">
 | 
			
		||||
									<CheckCircle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
 | 
			
		||||
									<span>
 | 
			
		||||
										<strong>We do NOT collect:</strong> IP addresses, hostnames,
 | 
			
		||||
										system details, or any personally identifiable information
 | 
			
		||||
									</span>
 | 
			
		||||
								</p>
 | 
			
		||||
								<p className="flex items-start">
 | 
			
		||||
									<CheckCircle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
 | 
			
		||||
									<span>
 | 
			
		||||
										<strong>We ONLY collect:</strong> An anonymous UUID (for
 | 
			
		||||
										deduplication) and the number of hosts you're monitoring
 | 
			
		||||
									</span>
 | 
			
		||||
								</p>
 | 
			
		||||
								<p className="flex items-start">
 | 
			
		||||
									<CheckCircle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
 | 
			
		||||
									<span>
 | 
			
		||||
										<strong>Purpose:</strong> Display a live counter on our
 | 
			
		||||
										website showing global PatchMon adoption
 | 
			
		||||
									</span>
 | 
			
		||||
								</p>
 | 
			
		||||
								<p className="flex items-start">
 | 
			
		||||
									<Globe className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
 | 
			
		||||
									<span>
 | 
			
		||||
										<strong>Open Source:</strong> All code is public and
 | 
			
		||||
										auditable on GitHub
 | 
			
		||||
									</span>
 | 
			
		||||
								</p>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				{/* Metrics Toggle */}
 | 
			
		||||
				<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
 | 
			
		||||
					<div className="flex items-start justify-between">
 | 
			
		||||
						<div className="flex-1">
 | 
			
		||||
							<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
 | 
			
		||||
								Enable Anonymous Metrics
 | 
			
		||||
							</h3>
 | 
			
		||||
							<p className="text-sm text-secondary-600 dark:text-secondary-400">
 | 
			
		||||
								Share anonymous usage statistics to help us showcase PatchMon's
 | 
			
		||||
								global adoption. Data is sent automatically every 24 hours.
 | 
			
		||||
							</p>
 | 
			
		||||
						</div>
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
							onClick={() =>
 | 
			
		||||
								toggleMetricsMutation.mutate(!metricsSettings?.metrics_enabled)
 | 
			
		||||
							}
 | 
			
		||||
							disabled={toggleMetricsMutation.isPending}
 | 
			
		||||
							className={`ml-4 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
 | 
			
		||||
								metricsSettings?.metrics_enabled
 | 
			
		||||
									? "bg-primary-600"
 | 
			
		||||
									: "bg-secondary-200 dark:bg-secondary-700"
 | 
			
		||||
							} ${toggleMetricsMutation.isPending ? "opacity-50" : ""}`}
 | 
			
		||||
						>
 | 
			
		||||
							<span
 | 
			
		||||
								className={`inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
 | 
			
		||||
									metricsSettings?.metrics_enabled
 | 
			
		||||
										? "translate-x-5"
 | 
			
		||||
										: "translate-x-0"
 | 
			
		||||
								}`}
 | 
			
		||||
							/>
 | 
			
		||||
						</button>
 | 
			
		||||
					</div>
 | 
			
		||||
 | 
			
		||||
					{/* Status */}
 | 
			
		||||
					<div className="mt-4 pt-4 border-t border-secondary-200 dark:border-secondary-700">
 | 
			
		||||
						<div className="flex items-center text-sm">
 | 
			
		||||
							{metricsSettings?.metrics_enabled ? (
 | 
			
		||||
								<>
 | 
			
		||||
									<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
 | 
			
		||||
									<span className="text-green-700 dark:text-green-400">
 | 
			
		||||
										Metrics enabled - Thank you for supporting PatchMon!
 | 
			
		||||
									</span>
 | 
			
		||||
								</>
 | 
			
		||||
							) : (
 | 
			
		||||
								<>
 | 
			
		||||
									<EyeOff className="h-4 w-4 text-secondary-500 mr-2" />
 | 
			
		||||
									<span className="text-secondary-600 dark:text-secondary-400">
 | 
			
		||||
										Metrics disabled - No data is being sent
 | 
			
		||||
									</span>
 | 
			
		||||
								</>
 | 
			
		||||
							)}
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				{/* Anonymous ID Section */}
 | 
			
		||||
				<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
 | 
			
		||||
					<div className="flex items-start justify-between mb-4">
 | 
			
		||||
						<div>
 | 
			
		||||
							<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
 | 
			
		||||
								Your Anonymous Instance ID
 | 
			
		||||
							</h3>
 | 
			
		||||
							<p className="text-sm text-secondary-600 dark:text-secondary-400">
 | 
			
		||||
								This UUID identifies your instance without revealing any
 | 
			
		||||
								personal information
 | 
			
		||||
							</p>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
 | 
			
		||||
					<div className="mt-4 space-y-4">
 | 
			
		||||
						<div className="flex items-center gap-3">
 | 
			
		||||
							<div className="flex-1 bg-secondary-50 dark:bg-secondary-700 rounded-md p-3 font-mono text-sm break-all">
 | 
			
		||||
								{maskId(metricsSettings?.metrics_anonymous_id)}
 | 
			
		||||
							</div>
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={() => setShowFullId(!showFullId)}
 | 
			
		||||
								className="p-2 text-secondary-600 dark:text-secondary-400 hover:text-secondary-900 dark:hover:text-white"
 | 
			
		||||
								title={showFullId ? "Hide ID" : "Show full ID"}
 | 
			
		||||
							>
 | 
			
		||||
								{showFullId ? (
 | 
			
		||||
									<EyeOff className="h-5 w-5" />
 | 
			
		||||
								) : (
 | 
			
		||||
									<Eye className="h-5 w-5" />
 | 
			
		||||
								)}
 | 
			
		||||
							</button>
 | 
			
		||||
						</div>
 | 
			
		||||
 | 
			
		||||
						<div className="flex gap-3">
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={() => regenerateIdMutation.mutate()}
 | 
			
		||||
								disabled={regenerateIdMutation.isPending}
 | 
			
		||||
								className="inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
 | 
			
		||||
							>
 | 
			
		||||
								{regenerateIdMutation.isPending ? (
 | 
			
		||||
									<>
 | 
			
		||||
										<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-secondary-700 dark:border-secondary-200 mr-2"></div>
 | 
			
		||||
										Regenerating...
 | 
			
		||||
									</>
 | 
			
		||||
								) : (
 | 
			
		||||
									<>
 | 
			
		||||
										<RefreshCw className="h-4 w-4 mr-2" />
 | 
			
		||||
										Regenerate ID
 | 
			
		||||
									</>
 | 
			
		||||
								)}
 | 
			
		||||
							</button>
 | 
			
		||||
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={() => sendNowMutation.mutate()}
 | 
			
		||||
								disabled={
 | 
			
		||||
									!metricsSettings?.metrics_enabled || sendNowMutation.isPending
 | 
			
		||||
								}
 | 
			
		||||
								className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
 | 
			
		||||
							>
 | 
			
		||||
								{sendNowMutation.isPending ? (
 | 
			
		||||
									<>
 | 
			
		||||
										<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
 | 
			
		||||
										Sending...
 | 
			
		||||
									</>
 | 
			
		||||
								) : (
 | 
			
		||||
									<>
 | 
			
		||||
										<Send className="h-4 w-4 mr-2" />
 | 
			
		||||
										Send Metrics Now
 | 
			
		||||
									</>
 | 
			
		||||
								)}
 | 
			
		||||
							</button>
 | 
			
		||||
						</div>
 | 
			
		||||
 | 
			
		||||
						{metricsSettings?.metrics_last_sent && (
 | 
			
		||||
							<p className="text-xs text-secondary-500 dark:text-secondary-400">
 | 
			
		||||
								Last sent:{" "}
 | 
			
		||||
								{new Date(metricsSettings.metrics_last_sent).toLocaleString()}
 | 
			
		||||
							</p>
 | 
			
		||||
						)}
 | 
			
		||||
					</div>
 | 
			
		||||
 | 
			
		||||
					{/* Success/Error Messages */}
 | 
			
		||||
					{regenerateIdMutation.isSuccess && (
 | 
			
		||||
						<div className="mt-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 rounded-md p-3">
 | 
			
		||||
							<div className="flex">
 | 
			
		||||
								<CheckCircle className="h-4 w-4 text-green-400 dark:text-green-300 mt-0.5" />
 | 
			
		||||
								<p className="ml-2 text-sm text-green-700 dark:text-green-300">
 | 
			
		||||
									Anonymous ID regenerated successfully
 | 
			
		||||
								</p>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					)}
 | 
			
		||||
 | 
			
		||||
					{sendNowMutation.isSuccess && (
 | 
			
		||||
						<div className="mt-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 rounded-md p-3">
 | 
			
		||||
							<div className="flex">
 | 
			
		||||
								<CheckCircle className="h-4 w-4 text-green-400 dark:text-green-300 mt-0.5" />
 | 
			
		||||
								<div className="ml-2 text-sm text-green-700 dark:text-green-300">
 | 
			
		||||
									<p className="font-medium">Metrics sent successfully!</p>
 | 
			
		||||
									{sendNowMutation.data?.data && (
 | 
			
		||||
										<p className="mt-1">
 | 
			
		||||
											Sent: {sendNowMutation.data.data.hostCount} hosts, version{" "}
 | 
			
		||||
											{sendNowMutation.data.data.version}
 | 
			
		||||
										</p>
 | 
			
		||||
									)}
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					)}
 | 
			
		||||
 | 
			
		||||
					{sendNowMutation.isError && (
 | 
			
		||||
						<div className="mt-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded-md p-3">
 | 
			
		||||
							<div className="flex">
 | 
			
		||||
								<AlertCircle className="h-4 w-4 text-red-400 dark:text-red-300 mt-0.5" />
 | 
			
		||||
								<div className="ml-2 text-sm text-red-700 dark:text-red-300">
 | 
			
		||||
									{sendNowMutation.error?.message || "Failed to send metrics"}
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					)}
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				{/* Information Panel */}
 | 
			
		||||
				<div className="bg-secondary-50 dark:bg-secondary-800/50 border border-secondary-200 dark:border-secondary-700 rounded-lg p-6">
 | 
			
		||||
					<div className="flex">
 | 
			
		||||
						<Info className="h-5 w-5 text-secondary-500 dark:text-secondary-400 flex-shrink-0 mt-0.5" />
 | 
			
		||||
						<div className="ml-3 text-sm text-secondary-700 dark:text-secondary-300">
 | 
			
		||||
							<h4 className="font-medium mb-2">How it works:</h4>
 | 
			
		||||
							<ul className="space-y-1 list-disc list-inside">
 | 
			
		||||
								<li>
 | 
			
		||||
									Metrics are sent automatically every 24 hours when enabled
 | 
			
		||||
								</li>
 | 
			
		||||
								<li>
 | 
			
		||||
									Only host count and version number are transmitted (no
 | 
			
		||||
									sensitive data)
 | 
			
		||||
								</li>
 | 
			
		||||
								<li>The anonymous UUID prevents duplicate counting</li>
 | 
			
		||||
								<li>You can regenerate your ID or opt-out at any time</li>
 | 
			
		||||
								<li>
 | 
			
		||||
									All collected data is displayed publicly on{" "}
 | 
			
		||||
									<a
 | 
			
		||||
										href="https://patchmon.net"
 | 
			
		||||
										target="_blank"
 | 
			
		||||
										rel="noopener noreferrer"
 | 
			
		||||
										className="text-primary-600 dark:text-primary-400 hover:underline"
 | 
			
		||||
									>
 | 
			
		||||
										patchmon.net
 | 
			
		||||
									</a>
 | 
			
		||||
								</li>
 | 
			
		||||
							</ul>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</SettingsLayout>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default SettingsMetrics;
 | 
			
		||||
@@ -5,7 +5,7 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || "/api/v1";
 | 
			
		||||
// Create axios instance with default config
 | 
			
		||||
const api = axios.create({
 | 
			
		||||
	baseURL: API_BASE_URL,
 | 
			
		||||
	timeout: 10000,
 | 
			
		||||
	timeout: 10000, // 10 seconds
 | 
			
		||||
	headers: {
 | 
			
		||||
		"Content-Type": "application/json",
 | 
			
		||||
	},
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import { Agent as HttpAgent } from "node:http";
 | 
			
		||||
import react from "@vitejs/plugin-react";
 | 
			
		||||
import { defineConfig } from "vite";
 | 
			
		||||
 | 
			
		||||
@@ -14,6 +15,15 @@ export default defineConfig({
 | 
			
		||||
				target: `http://${process.env.BACKEND_HOST || "localhost"}:${process.env.BACKEND_PORT || "3001"}`,
 | 
			
		||||
				changeOrigin: true,
 | 
			
		||||
				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:
 | 
			
		||||
					process.env.VITE_ENABLE_LOGGING === "true"
 | 
			
		||||
						? (proxy, _options) => {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2588
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2588
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
	"name": "patchmon",
 | 
			
		||||
	"version": "1.3.0",
 | 
			
		||||
	"version": "1.3.1",
 | 
			
		||||
	"description": "Linux Patch Monitoring System",
 | 
			
		||||
	"license": "AGPL-3.0",
 | 
			
		||||
	"private": true,
 | 
			
		||||
@@ -25,7 +25,7 @@
 | 
			
		||||
		"lint:fix": "biome check --write ."
 | 
			
		||||
	},
 | 
			
		||||
	"devDependencies": {
 | 
			
		||||
		"@biomejs/biome": "2.2.4",
 | 
			
		||||
		"@biomejs/biome": "^2.3.0",
 | 
			
		||||
		"concurrently": "^8.2.2",
 | 
			
		||||
		"lefthook": "^1.13.4"
 | 
			
		||||
	},
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										798
									
								
								setup.sh
									
									
									
									
									
								
							
							
						
						
									
										798
									
								
								setup.sh
									
									
									
									
									
								
							@@ -707,6 +707,10 @@ configure_redis() {
 | 
			
		||||
        chown redis:redis /etc/redis/users.acl
 | 
			
		||||
        chmod 640 /etc/redis/users.acl
 | 
			
		||||
        print_status "Created Redis ACL file"
 | 
			
		||||
    else
 | 
			
		||||
        # Backup existing ACL file
 | 
			
		||||
        cp /etc/redis/users.acl /etc/redis/users.acl.backup.$(date +%Y%m%d_%H%M%S) 2>/dev/null || true
 | 
			
		||||
        print_info "Backed up existing ACL file"
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Configure ACL file in redis.conf
 | 
			
		||||
@@ -727,8 +731,14 @@ configure_redis() {
 | 
			
		||||
        print_status "Removed user definitions from redis.conf"
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Create admin user in ACL file if it doesn't exist
 | 
			
		||||
    if ! grep -q "^user admin" /etc/redis/users.acl; then
 | 
			
		||||
    # Create or update admin user in ACL file
 | 
			
		||||
    if grep -q "^user admin" /etc/redis/users.acl; then
 | 
			
		||||
        print_info "Admin user already exists in ACL, updating password..."
 | 
			
		||||
        # Remove old admin line and add new one
 | 
			
		||||
        sed -i '/^user admin/d' /etc/redis/users.acl
 | 
			
		||||
        echo "user admin on sanitize-payload >$REDIS_PASSWORD ~* &* +@all" >> /etc/redis/users.acl
 | 
			
		||||
        print_status "Updated admin user password"
 | 
			
		||||
    else
 | 
			
		||||
        echo "user admin on sanitize-payload >$REDIS_PASSWORD ~* &* +@all" >> /etc/redis/users.acl
 | 
			
		||||
        print_status "Added admin user to ACL file"
 | 
			
		||||
    fi
 | 
			
		||||
@@ -737,65 +747,126 @@ configure_redis() {
 | 
			
		||||
    print_info "Restarting Redis to apply ACL configuration..."
 | 
			
		||||
    systemctl restart redis-server
 | 
			
		||||
    
 | 
			
		||||
    # Wait for Redis to start
 | 
			
		||||
    sleep 3
 | 
			
		||||
    # Wait for Redis to start with retry logic
 | 
			
		||||
    sleep 5
 | 
			
		||||
    
 | 
			
		||||
    # Test admin connection
 | 
			
		||||
    if ! redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ping > /dev/null 2>&1; then
 | 
			
		||||
        print_error "Failed to configure Redis ACL authentication"
 | 
			
		||||
        return 1
 | 
			
		||||
    fi
 | 
			
		||||
    # Test admin connection with retries
 | 
			
		||||
    local max_retries=3
 | 
			
		||||
    local retry=0
 | 
			
		||||
    local admin_works=false
 | 
			
		||||
    
 | 
			
		||||
    print_status "Redis ACL authentication configuration successful"
 | 
			
		||||
    
 | 
			
		||||
    # Create Redis user with ACL
 | 
			
		||||
    print_info "Creating Redis ACL user: $REDIS_USER"
 | 
			
		||||
    
 | 
			
		||||
    # Create user with password and permissions - capture output for error handling
 | 
			
		||||
    local acl_result
 | 
			
		||||
    acl_result=$(redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL SETUSER "$REDIS_USER" on ">${REDIS_USER_PASSWORD}" ~* +@all 2>&1)
 | 
			
		||||
    
 | 
			
		||||
    if [ "$acl_result" = "OK" ]; then
 | 
			
		||||
        print_status "Redis user '$REDIS_USER' created successfully"
 | 
			
		||||
        
 | 
			
		||||
        # Save ACL users to file to persist across restarts
 | 
			
		||||
        local save_result
 | 
			
		||||
        save_result=$(redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL SAVE 2>&1)
 | 
			
		||||
        
 | 
			
		||||
        if [ "$save_result" = "OK" ]; then
 | 
			
		||||
            print_status "Redis ACL users saved to file"
 | 
			
		||||
        else
 | 
			
		||||
            print_warning "Failed to save ACL users to file: $save_result"
 | 
			
		||||
    while [ $retry -lt $max_retries ]; do
 | 
			
		||||
        if redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ping > /dev/null 2>&1; then
 | 
			
		||||
            admin_works=true
 | 
			
		||||
            break
 | 
			
		||||
        fi
 | 
			
		||||
        print_info "Waiting for Redis to be ready... (attempt $((retry + 1))/$max_retries)"
 | 
			
		||||
        sleep 2
 | 
			
		||||
        retry=$((retry + 1))
 | 
			
		||||
    done
 | 
			
		||||
    
 | 
			
		||||
    if [ "$admin_works" = false ]; then
 | 
			
		||||
        print_error "Failed to verify admin connection after Redis restart"
 | 
			
		||||
        print_error "Redis ACL configuration may have issues"
 | 
			
		||||
        
 | 
			
		||||
        # Verify user was actually created
 | 
			
		||||
        local verify_result
 | 
			
		||||
        verify_result=$(redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL GETUSER "$REDIS_USER" 2>&1)
 | 
			
		||||
        # Try to fix by disabling ACL and using requirepass instead
 | 
			
		||||
        print_warning "Attempting fallback: using requirepass instead of ACL..."
 | 
			
		||||
        sed -i 's/^aclfile/# aclfile/' /etc/redis/redis.conf
 | 
			
		||||
        sed -i "s/^# requirepass .*/requirepass $REDIS_PASSWORD/" /etc/redis/redis.conf
 | 
			
		||||
        if ! grep -q "^requirepass" /etc/redis/redis.conf; then
 | 
			
		||||
            echo "requirepass $REDIS_PASSWORD" >> /etc/redis/redis.conf
 | 
			
		||||
        fi
 | 
			
		||||
        systemctl restart redis-server
 | 
			
		||||
        sleep 3
 | 
			
		||||
        
 | 
			
		||||
        if [ "$verify_result" = "(nil)" ]; then
 | 
			
		||||
            print_error "User creation reported OK but user does not exist"
 | 
			
		||||
        # Test requirepass
 | 
			
		||||
        if redis-cli -h 127.0.0.1 -p 6379 -a "$REDIS_PASSWORD" --no-auth-warning ping > /dev/null 2>&1; then
 | 
			
		||||
            print_status "Fallback successful - using requirepass authentication"
 | 
			
		||||
            # For requirepass mode, we'll set REDIS_USER empty later
 | 
			
		||||
            print_info "Note: Using legacy requirepass mode instead of ACL"
 | 
			
		||||
        else
 | 
			
		||||
            print_error "Fallback also failed - Redis authentication is broken"
 | 
			
		||||
            return 1
 | 
			
		||||
        fi
 | 
			
		||||
    else
 | 
			
		||||
        print_error "Failed to create Redis user: $acl_result"
 | 
			
		||||
        return 1
 | 
			
		||||
        print_status "Redis ACL authentication configuration successful"
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Test user connection
 | 
			
		||||
    print_info "Testing Redis user connection..."
 | 
			
		||||
    if redis-cli -h 127.0.0.1 -p 6379 --user "$REDIS_USER" --pass "$REDIS_USER_PASSWORD" --no-auth-warning -n "$REDIS_DB" ping > /dev/null 2>&1; then
 | 
			
		||||
        print_status "Redis user connection test successful"
 | 
			
		||||
    # Create Redis user with ACL (only if admin_works, meaning we're using ACL mode)
 | 
			
		||||
    if [ "$admin_works" = true ]; then
 | 
			
		||||
        print_info "Creating Redis ACL user: $REDIS_USER"
 | 
			
		||||
        
 | 
			
		||||
        # Create user with password and permissions - capture output for error handling
 | 
			
		||||
        local acl_result
 | 
			
		||||
        acl_result=$(redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL SETUSER "$REDIS_USER" on ">${REDIS_USER_PASSWORD}" ~* +@all 2>&1)
 | 
			
		||||
        
 | 
			
		||||
        if [ "$acl_result" = "OK" ]; then
 | 
			
		||||
            print_status "Redis user '$REDIS_USER' created successfully"
 | 
			
		||||
            
 | 
			
		||||
            # Save ACL users to file to persist across restarts
 | 
			
		||||
            local save_result
 | 
			
		||||
            save_result=$(redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL SAVE 2>&1)
 | 
			
		||||
            
 | 
			
		||||
            if [ "$save_result" = "OK" ]; then
 | 
			
		||||
                print_status "Redis ACL users saved to file"
 | 
			
		||||
            else
 | 
			
		||||
                print_warning "Failed to save ACL users to file: $save_result"
 | 
			
		||||
            fi
 | 
			
		||||
            
 | 
			
		||||
            # Verify user was actually created
 | 
			
		||||
            local verify_result
 | 
			
		||||
            verify_result=$(redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL GETUSER "$REDIS_USER" 2>&1)
 | 
			
		||||
            
 | 
			
		||||
            if [ "$verify_result" = "(nil)" ]; then
 | 
			
		||||
                print_error "User creation reported OK but user does not exist"
 | 
			
		||||
                return 1
 | 
			
		||||
            fi
 | 
			
		||||
            
 | 
			
		||||
            # Test user connection
 | 
			
		||||
            print_info "Testing Redis user connection..."
 | 
			
		||||
            if redis-cli -h 127.0.0.1 -p 6379 --user "$REDIS_USER" --pass "$REDIS_USER_PASSWORD" --no-auth-warning -n "$REDIS_DB" ping > /dev/null 2>&1; then
 | 
			
		||||
                print_status "Redis user connection test successful"
 | 
			
		||||
            else
 | 
			
		||||
                print_error "Redis user connection test failed"
 | 
			
		||||
                return 1
 | 
			
		||||
            fi
 | 
			
		||||
            
 | 
			
		||||
            # Mark the selected database as in-use
 | 
			
		||||
            redis-cli -h 127.0.0.1 -p 6379 --user "$REDIS_USER" --pass "$REDIS_USER_PASSWORD" --no-auth-warning -n "$REDIS_DB" SET "patchmon:initialized" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > /dev/null
 | 
			
		||||
            print_status "Marked Redis database $REDIS_DB as in-use"
 | 
			
		||||
        else
 | 
			
		||||
            print_error "Failed to create Redis user: $acl_result"
 | 
			
		||||
            return 1
 | 
			
		||||
        fi
 | 
			
		||||
    else
 | 
			
		||||
        print_error "Redis user connection test failed"
 | 
			
		||||
        return 1
 | 
			
		||||
        # Using requirepass mode - no per-user ACL
 | 
			
		||||
        print_info "Using requirepass mode - testing connection..."
 | 
			
		||||
        
 | 
			
		||||
        # For requirepass, we don't use username, just password
 | 
			
		||||
        if redis-cli -h 127.0.0.1 -p 6379 -a "$REDIS_PASSWORD" --no-auth-warning -n "$REDIS_DB" ping > /dev/null 2>&1; then
 | 
			
		||||
            print_status "Redis requirepass connection test successful"
 | 
			
		||||
            
 | 
			
		||||
            # Mark the selected database as in-use
 | 
			
		||||
            redis-cli -h 127.0.0.1 -p 6379 -a "$REDIS_PASSWORD" --no-auth-warning -n "$REDIS_DB" SET "patchmon:initialized" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > /dev/null
 | 
			
		||||
            print_status "Marked Redis database $REDIS_DB as in-use"
 | 
			
		||||
            
 | 
			
		||||
            # Set REDIS_USER to empty for requirepass mode
 | 
			
		||||
            REDIS_USER=""
 | 
			
		||||
            REDIS_USER_PASSWORD="$REDIS_PASSWORD"
 | 
			
		||||
        else
 | 
			
		||||
            print_error "Redis requirepass connection test failed"
 | 
			
		||||
            return 1
 | 
			
		||||
        fi
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Mark the selected database as in-use
 | 
			
		||||
    redis-cli -h 127.0.0.1 -p 6379 --user "$REDIS_USER" --pass "$REDIS_USER_PASSWORD" --no-auth-warning -n "$REDIS_DB" SET "patchmon:initialized" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > /dev/null
 | 
			
		||||
    print_status "Marked Redis database $REDIS_DB as in-use"
 | 
			
		||||
    
 | 
			
		||||
    # Note: Redis credentials will be written to .env by create_env_files() function
 | 
			
		||||
    print_status "Redis user '$REDIS_USER' configured successfully"
 | 
			
		||||
    print_status "Redis configured successfully"
 | 
			
		||||
    
 | 
			
		||||
    if [ -n "$REDIS_USER" ]; then
 | 
			
		||||
        print_info "Redis Mode: ACL with user '$REDIS_USER'"
 | 
			
		||||
    else
 | 
			
		||||
        print_info "Redis Mode: requirepass (legacy single-password auth)"
 | 
			
		||||
    fi
 | 
			
		||||
    print_info "Redis credentials will be saved to backend/.env"
 | 
			
		||||
    
 | 
			
		||||
    return 0
 | 
			
		||||
@@ -1060,6 +1131,13 @@ DATABASE_URL="postgresql://$DB_USER:$DB_PASS@localhost:5432/$DB_NAME"
 | 
			
		||||
PM_DB_CONN_MAX_ATTEMPTS=30
 | 
			
		||||
PM_DB_CONN_WAIT_INTERVAL=2
 | 
			
		||||
 | 
			
		||||
# 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
 | 
			
		||||
 | 
			
		||||
# JWT Configuration
 | 
			
		||||
JWT_SECRET="$JWT_SECRET"
 | 
			
		||||
JWT_EXPIRES_IN=1h
 | 
			
		||||
@@ -1110,22 +1188,127 @@ EOF
 | 
			
		||||
    cat > frontend/.env << EOF
 | 
			
		||||
VITE_API_URL=$SERVER_PROTOCOL_SEL://$FQDN/api/v1
 | 
			
		||||
VITE_APP_NAME=PatchMon
 | 
			
		||||
VITE_APP_VERSION=1.3.0
 | 
			
		||||
VITE_APP_VERSION=1.3.1
 | 
			
		||||
EOF
 | 
			
		||||
 | 
			
		||||
    print_status "Environment files created"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Run database migrations
 | 
			
		||||
# Check and fix failed Prisma migrations
 | 
			
		||||
fix_failed_migrations() {
 | 
			
		||||
    local db_name="$1"
 | 
			
		||||
    local db_user="$2"
 | 
			
		||||
    local db_pass="$3"
 | 
			
		||||
    local db_host="${4:-localhost}"
 | 
			
		||||
    local max_retries=3
 | 
			
		||||
    
 | 
			
		||||
    print_info "Checking for failed migrations in database..."
 | 
			
		||||
    
 | 
			
		||||
    # Query for failed migrations (where started_at is set but finished_at is NULL)
 | 
			
		||||
    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
 | 
			
		||||
    
 | 
			
		||||
    print_info "Attempting to resolve failed migrations..."
 | 
			
		||||
    
 | 
			
		||||
    # For each failed migration, mark it as rolled back and remove it
 | 
			
		||||
    echo "$failed_migrations" | while read -r migration; do
 | 
			
		||||
        if [ -n "$migration" ]; then
 | 
			
		||||
            print_info "Processing failed migration: $migration"
 | 
			
		||||
            
 | 
			
		||||
            # Mark the migration 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 migration record to allow retry
 | 
			
		||||
            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 "Marked migration '$migration' for retry"
 | 
			
		||||
        fi
 | 
			
		||||
    done
 | 
			
		||||
    
 | 
			
		||||
    print_status "Failed migrations have been cleared for retry"
 | 
			
		||||
    return 0
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Run database migrations with self-healing
 | 
			
		||||
run_migrations() {
 | 
			
		||||
    print_info "Running database migrations as user $INSTANCE_USER..."
 | 
			
		||||
    
 | 
			
		||||
    cd "$APP_DIR/backend"
 | 
			
		||||
    # Suppress Prisma CLI output (still logged to install log via tee)
 | 
			
		||||
    run_as_user "$INSTANCE_USER" "cd $APP_DIR/backend && npx prisma migrate deploy" >/dev/null 2>&1 || true
 | 
			
		||||
    
 | 
			
		||||
    local max_attempts=3
 | 
			
		||||
    local attempt=1
 | 
			
		||||
    local migration_success=false
 | 
			
		||||
    
 | 
			
		||||
    while [ $attempt -le $max_attempts ]; do
 | 
			
		||||
        print_info "Migration attempt $attempt of $max_attempts..."
 | 
			
		||||
        
 | 
			
		||||
        # Try to run migrations
 | 
			
		||||
        local migrate_output
 | 
			
		||||
        migrate_output=$(run_as_user "$INSTANCE_USER" "cd $APP_DIR/backend && npx prisma migrate deploy 2>&1" || echo "MIGRATION_FAILED")
 | 
			
		||||
        
 | 
			
		||||
        # Check if migration succeeded
 | 
			
		||||
        if ! echo "$migrate_output" | grep -q "MIGRATION_FAILED\|Error:\|P3009"; then
 | 
			
		||||
            print_status "Migrations completed successfully"
 | 
			
		||||
            migration_success=true
 | 
			
		||||
            break
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        # Check specifically for P3009 (failed migrations found)
 | 
			
		||||
        if echo "$migrate_output" | grep -q "P3009\|migrate found failed migrations"; then
 | 
			
		||||
            print_warning "Detected failed migrations (P3009 error)"
 | 
			
		||||
            
 | 
			
		||||
            # Extract the failed migration name if possible
 | 
			
		||||
            local failed_migration
 | 
			
		||||
            failed_migration=$(echo "$migrate_output" | grep -oP "The \`\K[^\`]+" | head -1 || echo "")
 | 
			
		||||
            
 | 
			
		||||
            if [ -n "$failed_migration" ]; then
 | 
			
		||||
                print_info "Failed migration identified: $failed_migration"
 | 
			
		||||
            fi
 | 
			
		||||
            
 | 
			
		||||
            # Attempt to fix failed migrations
 | 
			
		||||
            print_info "Attempting to self-heal migration issues..."
 | 
			
		||||
            if fix_failed_migrations "$DB_NAME" "$DB_USER" "$DB_PASS" "localhost"; then
 | 
			
		||||
                print_status "Migration issues resolved, retrying..."
 | 
			
		||||
                attempt=$((attempt + 1))
 | 
			
		||||
                sleep 2
 | 
			
		||||
                continue
 | 
			
		||||
            else
 | 
			
		||||
                print_error "Failed to resolve migration issues"
 | 
			
		||||
                break
 | 
			
		||||
            fi
 | 
			
		||||
        else
 | 
			
		||||
            # Other migration error
 | 
			
		||||
            print_error "Migration failed with error:"
 | 
			
		||||
            echo "$migrate_output" | grep -A 5 "Error:"
 | 
			
		||||
            break
 | 
			
		||||
        fi
 | 
			
		||||
    done
 | 
			
		||||
    
 | 
			
		||||
    if [ "$migration_success" = false ]; then
 | 
			
		||||
        print_error "Migrations failed after $max_attempts attempts"
 | 
			
		||||
        print_info "You may need to manually resolve migration issues"
 | 
			
		||||
        print_info "Check migrations: cd $APP_DIR/backend && npx prisma migrate status"
 | 
			
		||||
        return 1
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Generate Prisma client
 | 
			
		||||
    run_as_user "$INSTANCE_USER" "cd $APP_DIR/backend && npx prisma generate" >/dev/null 2>&1 || true
 | 
			
		||||
    
 | 
			
		||||
    print_status "Database migrations completed as $INSTANCE_USER"
 | 
			
		||||
    return 0
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Admin account creation removed - handled by application's first-time setup
 | 
			
		||||
@@ -1462,7 +1645,60 @@ start_services() {
 | 
			
		||||
        print_status "PatchMon service started successfully"
 | 
			
		||||
    else
 | 
			
		||||
        print_error "Failed to start PatchMon service"
 | 
			
		||||
        systemctl status "$SERVICE_NAME"
 | 
			
		||||
        echo ""
 | 
			
		||||
        
 | 
			
		||||
        # Show last 25 lines of service logs for debugging
 | 
			
		||||
        print_warning "=== Last 25 lines of service logs ==="
 | 
			
		||||
        journalctl -u "$SERVICE_NAME" -n 25 --no-pager || true
 | 
			
		||||
        print_warning "==================================="
 | 
			
		||||
        echo ""
 | 
			
		||||
        
 | 
			
		||||
        # Check for specific error patterns
 | 
			
		||||
        local logs=$(journalctl -u "$SERVICE_NAME" -n 50 --no-pager 2>/dev/null || echo "")
 | 
			
		||||
        
 | 
			
		||||
        if echo "$logs" | grep -q "WRONGPASS\|NOAUTH"; then
 | 
			
		||||
            print_error "❌ Detected Redis authentication error!"
 | 
			
		||||
            print_info "The service cannot authenticate with Redis."
 | 
			
		||||
            echo ""
 | 
			
		||||
            print_info "Current Redis configuration in .env:"
 | 
			
		||||
            grep "^REDIS_" "$APP_DIR/backend/.env" || true
 | 
			
		||||
            echo ""
 | 
			
		||||
            print_info "Debug steps:"
 | 
			
		||||
            print_info "  1. Check Redis is running:"
 | 
			
		||||
            print_info "     systemctl status redis-server"
 | 
			
		||||
            echo ""
 | 
			
		||||
            print_info "  2. Check Redis ACL users:"
 | 
			
		||||
            print_info "     redis-cli ACL LIST"
 | 
			
		||||
            echo ""
 | 
			
		||||
            print_info "  3. Test Redis connection:"
 | 
			
		||||
            local test_user=$(grep "^REDIS_USER=" "$APP_DIR/backend/.env" | cut -d'=' -f2)
 | 
			
		||||
            local test_pass=$(grep "^REDIS_PASSWORD=" "$APP_DIR/backend/.env" | cut -d'=' -f2)
 | 
			
		||||
            local test_db=$(grep "^REDIS_DB=" "$APP_DIR/backend/.env" | cut -d'=' -f2)
 | 
			
		||||
            print_info "     redis-cli --user $test_user --pass $test_pass -n ${test_db:-0} ping"
 | 
			
		||||
            echo ""
 | 
			
		||||
            print_info "  4. Check Redis configuration files:"
 | 
			
		||||
            print_info "     cat /etc/redis/redis.conf | grep aclfile"
 | 
			
		||||
            print_info "     cat /etc/redis/users.acl"
 | 
			
		||||
            echo ""
 | 
			
		||||
        elif echo "$logs" | grep -q "ECONNREFUSED.*postgresql\|Connection refused.*5432"; then
 | 
			
		||||
            print_error "❌ Detected PostgreSQL connection error!"
 | 
			
		||||
            print_info "Check if PostgreSQL is running:"
 | 
			
		||||
            print_info "  systemctl status postgresql"
 | 
			
		||||
        elif echo "$logs" | grep -q "ECONNREFUSED.*redis\|Connection refused.*6379"; then
 | 
			
		||||
            print_error "❌ Detected Redis connection error!"
 | 
			
		||||
            print_info "Check if Redis is running:"
 | 
			
		||||
            print_info "  systemctl status redis-server"
 | 
			
		||||
        elif echo "$logs" | grep -q "database.*does not exist"; then
 | 
			
		||||
            print_error "❌ Database does not exist!"
 | 
			
		||||
            print_info "Database: $DB_NAME"
 | 
			
		||||
        elif echo "$logs" | grep -q "Error:"; then
 | 
			
		||||
            print_error "❌ Application error detected in logs"
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        echo ""
 | 
			
		||||
        print_info "View full logs: journalctl -u $SERVICE_NAME -f"
 | 
			
		||||
        print_info "Check service status: systemctl status $SERVICE_NAME"
 | 
			
		||||
        
 | 
			
		||||
        return 1
 | 
			
		||||
    fi
 | 
			
		||||
}
 | 
			
		||||
@@ -2012,6 +2248,65 @@ select_installation_to_update() {
 | 
			
		||||
    done
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Repair/recreate Redis user with correct permissions
 | 
			
		||||
repair_redis_user() {
 | 
			
		||||
    local redis_user="$1"
 | 
			
		||||
    local redis_pass="$2"
 | 
			
		||||
    local redis_db="${3:-0}"
 | 
			
		||||
    
 | 
			
		||||
    print_info "Attempting to repair Redis user: $redis_user"
 | 
			
		||||
    
 | 
			
		||||
    # Find admin password
 | 
			
		||||
    local admin_password=""
 | 
			
		||||
    if [ -f /etc/redis/users.acl ] && grep -q "^user admin" /etc/redis/users.acl; then
 | 
			
		||||
        admin_password=$(grep "^user admin" /etc/redis/users.acl | grep -oP '>\K[^ ]+' | head -1)
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    if [ -z "$admin_password" ]; then
 | 
			
		||||
        print_error "Cannot repair Redis user - no admin credentials found"
 | 
			
		||||
        return 1
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Test admin connection
 | 
			
		||||
    if ! redis-cli -h localhost -p 6379 --user admin --pass "$admin_password" --no-auth-warning ping >/dev/null 2>&1; then
 | 
			
		||||
        print_error "Admin credentials don't work - cannot repair user"
 | 
			
		||||
        return 1
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    print_status "Admin access confirmed"
 | 
			
		||||
    
 | 
			
		||||
    # Delete existing user if it exists (and is broken)
 | 
			
		||||
    print_info "Removing old user configuration..."
 | 
			
		||||
    redis-cli -h localhost -p 6379 --user admin --pass "$admin_password" --no-auth-warning ACL DELUSER "$redis_user" >/dev/null 2>&1 || true
 | 
			
		||||
    
 | 
			
		||||
    # Create user with full permissions
 | 
			
		||||
    print_info "Creating user with full permissions..."
 | 
			
		||||
    local create_result
 | 
			
		||||
    create_result=$(redis-cli -h localhost -p 6379 --user admin --pass "$admin_password" --no-auth-warning ACL SETUSER "$redis_user" on ">${redis_pass}" ~* +@all 2>&1)
 | 
			
		||||
    
 | 
			
		||||
    if echo "$create_result" | grep -q "OK"; then
 | 
			
		||||
        # Save ACL
 | 
			
		||||
        redis-cli -h localhost -p 6379 --user admin --pass "$admin_password" --no-auth-warning ACL SAVE >/dev/null 2>&1
 | 
			
		||||
        
 | 
			
		||||
        # Verify the new user works
 | 
			
		||||
        if redis-cli -h localhost -p 6379 --user "$redis_user" --pass "$redis_pass" --no-auth-warning -n "$redis_db" ping >/dev/null 2>&1; then
 | 
			
		||||
            if redis-cli -h localhost -p 6379 --user "$redis_user" --pass "$redis_pass" --no-auth-warning -n "$redis_db" info >/dev/null 2>&1; then
 | 
			
		||||
                print_status "Redis user repaired successfully"
 | 
			
		||||
                return 0
 | 
			
		||||
            else
 | 
			
		||||
                print_error "User created but INFO command still fails"
 | 
			
		||||
                return 1
 | 
			
		||||
            fi
 | 
			
		||||
        else
 | 
			
		||||
            print_error "User created but PING command fails"
 | 
			
		||||
            return 1
 | 
			
		||||
        fi
 | 
			
		||||
    else
 | 
			
		||||
        print_error "Failed to create user: $create_result"
 | 
			
		||||
        return 1
 | 
			
		||||
    fi
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Check and update Redis configuration for existing installation
 | 
			
		||||
update_redis_configuration() {
 | 
			
		||||
    print_info "Checking Redis configuration..."
 | 
			
		||||
@@ -2021,12 +2316,57 @@ update_redis_configuration() {
 | 
			
		||||
        if grep -q "^REDIS_HOST=" "$instance_dir/backend/.env" && \
 | 
			
		||||
           grep -q "^REDIS_PASSWORD=" "$instance_dir/backend/.env"; then
 | 
			
		||||
            print_status "Redis configuration already exists in .env"
 | 
			
		||||
            return 0
 | 
			
		||||
            
 | 
			
		||||
            # Verify the credentials actually work
 | 
			
		||||
            local redis_user=$(grep "^REDIS_USER=" "$instance_dir/backend/.env" | cut -d'=' -f2 | tr -d '"')
 | 
			
		||||
            local redis_pass=$(grep "^REDIS_PASSWORD=" "$instance_dir/backend/.env" | cut -d'=' -f2 | tr -d '"')
 | 
			
		||||
            local redis_db=$(grep "^REDIS_DB=" "$instance_dir/backend/.env" | cut -d'=' -f2 | tr -d '"')
 | 
			
		||||
            
 | 
			
		||||
            if [ -n "$redis_user" ] && [ -n "$redis_pass" ]; then
 | 
			
		||||
                # Test with username and password
 | 
			
		||||
                local ping_works=false
 | 
			
		||||
                local info_works=false
 | 
			
		||||
                
 | 
			
		||||
                if redis-cli -h localhost -p 6379 --user "$redis_user" --pass "$redis_pass" --no-auth-warning -n "${redis_db:-0}" ping >/dev/null 2>&1; then
 | 
			
		||||
                    ping_works=true
 | 
			
		||||
                fi
 | 
			
		||||
                
 | 
			
		||||
                if redis-cli -h localhost -p 6379 --user "$redis_user" --pass "$redis_pass" --no-auth-warning -n "${redis_db:-0}" info >/dev/null 2>&1; then
 | 
			
		||||
                    info_works=true
 | 
			
		||||
                fi
 | 
			
		||||
                
 | 
			
		||||
                if [ "$ping_works" = true ] && [ "$info_works" = true ]; then
 | 
			
		||||
                    print_status "Redis credentials verified with redis-cli (tested ping and info commands)"
 | 
			
		||||
                    
 | 
			
		||||
                    # Force refresh the Redis user during updates to ensure correct ACL permissions
 | 
			
		||||
                    # This prevents issues where redis-cli works but Node.js client doesn't
 | 
			
		||||
                    print_info "Refreshing Redis user permissions to ensure compatibility..."
 | 
			
		||||
                    
 | 
			
		||||
                    if repair_redis_user "$redis_user" "$redis_pass" "$redis_db"; then
 | 
			
		||||
                        print_status "Redis user permissions refreshed successfully"
 | 
			
		||||
                        return 0
 | 
			
		||||
                    else
 | 
			
		||||
                        print_warning "Could not refresh Redis user, but credentials seem to work - continuing..."
 | 
			
		||||
                        return 0
 | 
			
		||||
                    fi
 | 
			
		||||
                else
 | 
			
		||||
                    print_warning "Redis credentials not working properly (ping: $ping_works, info: $info_works)"
 | 
			
		||||
                    print_info "Attempting to repair Redis user..."
 | 
			
		||||
                    
 | 
			
		||||
                    if repair_redis_user "$redis_user" "$redis_pass" "$redis_db"; then
 | 
			
		||||
                        print_status "Redis user repaired successfully"
 | 
			
		||||
                        return 0
 | 
			
		||||
                    else
 | 
			
		||||
                        print_warning "Could not repair Redis user, will reconfigure from scratch..."
 | 
			
		||||
                    fi
 | 
			
		||||
                fi
 | 
			
		||||
            else
 | 
			
		||||
                print_warning "Redis credentials incomplete in .env (missing user or password)"
 | 
			
		||||
            fi
 | 
			
		||||
        fi
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    print_warning "Redis configuration not found in .env - this is a legacy installation"
 | 
			
		||||
    print_info "Setting up Redis for this instance..."
 | 
			
		||||
    print_warning "Redis configuration not found or invalid in .env - setting up Redis for this instance..."
 | 
			
		||||
    
 | 
			
		||||
    # Detect package manager if not already set
 | 
			
		||||
    if [ -z "$PKG_INSTALL" ]; then
 | 
			
		||||
@@ -2054,6 +2394,39 @@ update_redis_configuration() {
 | 
			
		||||
    REDIS_USER="patchmon_${DB_SAFE_NAME}"
 | 
			
		||||
    REDIS_USER_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32)
 | 
			
		||||
    
 | 
			
		||||
    # Test Redis connection to determine authentication status
 | 
			
		||||
    print_info "Testing Redis authentication status..."
 | 
			
		||||
    local needs_auth=false
 | 
			
		||||
    local admin_password=""
 | 
			
		||||
    
 | 
			
		||||
    # Try ping without auth
 | 
			
		||||
    if redis-cli -h localhost -p 6379 ping >/dev/null 2>&1; then
 | 
			
		||||
        print_info "Redis is accessible without authentication"
 | 
			
		||||
        needs_auth=false
 | 
			
		||||
    else
 | 
			
		||||
        print_info "Redis requires authentication"
 | 
			
		||||
        needs_auth=true
 | 
			
		||||
        
 | 
			
		||||
        # Try to find existing admin password from ACL file
 | 
			
		||||
        if [ -f /etc/redis/users.acl ] && grep -q "^user admin" /etc/redis/users.acl; then
 | 
			
		||||
            # Extract password from ACL file (format: >password)
 | 
			
		||||
            admin_password=$(grep "^user admin" /etc/redis/users.acl | grep -oP '>\K[^ ]+' | head -1)
 | 
			
		||||
            
 | 
			
		||||
            if [ -n "$admin_password" ]; then
 | 
			
		||||
                print_info "Found existing admin credentials in ACL file"
 | 
			
		||||
                
 | 
			
		||||
                # Test admin credentials
 | 
			
		||||
                if redis-cli -h localhost -p 6379 --user admin --pass "$admin_password" --no-auth-warning ping >/dev/null 2>&1; then
 | 
			
		||||
                    print_status "Existing admin credentials work"
 | 
			
		||||
                    REDIS_PASSWORD="$admin_password"
 | 
			
		||||
                else
 | 
			
		||||
                    print_warning "Existing admin credentials don't work, will create new configuration"
 | 
			
		||||
                    admin_password=""
 | 
			
		||||
                fi
 | 
			
		||||
            fi
 | 
			
		||||
        fi
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Find available Redis database
 | 
			
		||||
    print_info "Finding available Redis database..."
 | 
			
		||||
    local redis_db=0
 | 
			
		||||
@@ -2061,9 +2434,14 @@ update_redis_configuration() {
 | 
			
		||||
    
 | 
			
		||||
    while [ $redis_db -lt $max_attempts ]; do
 | 
			
		||||
        local key_count
 | 
			
		||||
        key_count=$(redis-cli -h localhost -p 6379 -n "$redis_db" DBSIZE 2>&1 | grep -v "ERR" || echo "1")
 | 
			
		||||
        
 | 
			
		||||
        if [ "$key_count" = "0" ] || [ "$key_count" = "(integer) 0" ]; then
 | 
			
		||||
        if [ "$needs_auth" = true ] && [ -n "$admin_password" ]; then
 | 
			
		||||
            key_count=$(redis-cli -h localhost -p 6379 --user admin --pass "$admin_password" --no-auth-warning -n "$redis_db" DBSIZE 2>&1 | grep -oP '\d+' || echo "1")
 | 
			
		||||
        else
 | 
			
		||||
            key_count=$(redis-cli -h localhost -p 6379 -n "$redis_db" DBSIZE 2>&1 | grep -oP '\d+' || echo "1")
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        if [ "$key_count" = "0" ]; then
 | 
			
		||||
            print_status "Found available Redis database: $redis_db"
 | 
			
		||||
            REDIS_DB=$redis_db
 | 
			
		||||
            break
 | 
			
		||||
@@ -2076,50 +2454,146 @@ update_redis_configuration() {
 | 
			
		||||
        REDIS_DB=0
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Generate admin password if not exists
 | 
			
		||||
    REDIS_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32)
 | 
			
		||||
    
 | 
			
		||||
    # Configure Redis with ACL if needed
 | 
			
		||||
    print_info "Configuring Redis ACL..."
 | 
			
		||||
    
 | 
			
		||||
    # Create ACL file if it doesn't exist
 | 
			
		||||
    if [ ! -f /etc/redis/users.acl ]; then
 | 
			
		||||
        touch /etc/redis/users.acl
 | 
			
		||||
        chown redis:redis /etc/redis/users.acl
 | 
			
		||||
        chmod 640 /etc/redis/users.acl
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Configure ACL file in redis.conf
 | 
			
		||||
    if ! grep -q "^aclfile" /etc/redis/redis.conf 2>/dev/null; then
 | 
			
		||||
        echo "aclfile /etc/redis/users.acl" >> /etc/redis/redis.conf
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Remove requirepass (incompatible with ACL)
 | 
			
		||||
    if grep -q "^requirepass" /etc/redis/redis.conf 2>/dev/null; then
 | 
			
		||||
        sed -i 's/^requirepass.*/# &/' /etc/redis/redis.conf
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Create admin user if it doesn't exist
 | 
			
		||||
    if ! grep -q "^user admin" /etc/redis/users.acl; then
 | 
			
		||||
        echo "user admin on sanitize-payload >$REDIS_PASSWORD ~* &* +@all" >> /etc/redis/users.acl
 | 
			
		||||
    if [ "$needs_auth" = false ]; then
 | 
			
		||||
        print_info "Configuring Redis ACL for security..."
 | 
			
		||||
        
 | 
			
		||||
        # Generate new admin password
 | 
			
		||||
        REDIS_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32)
 | 
			
		||||
        
 | 
			
		||||
        # Backup redis.conf
 | 
			
		||||
        if [ -f /etc/redis/redis.conf ]; then
 | 
			
		||||
            cp /etc/redis/redis.conf /etc/redis/redis.conf.backup.$(date +%Y%m%d_%H%M%S) 2>/dev/null || true
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        # Create ACL file if it doesn't exist
 | 
			
		||||
        if [ ! -f /etc/redis/users.acl ]; then
 | 
			
		||||
            touch /etc/redis/users.acl
 | 
			
		||||
            chown redis:redis /etc/redis/users.acl
 | 
			
		||||
            chmod 640 /etc/redis/users.acl
 | 
			
		||||
            print_status "Created Redis ACL file"
 | 
			
		||||
        else
 | 
			
		||||
            # Backup existing ACL file
 | 
			
		||||
            cp /etc/redis/users.acl /etc/redis/users.acl.backup.$(date +%Y%m%d_%H%M%S) 2>/dev/null || true
 | 
			
		||||
            print_info "Backed up existing ACL file"
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        # Configure ACL file in redis.conf
 | 
			
		||||
        if ! grep -q "^aclfile" /etc/redis/redis.conf 2>/dev/null; then
 | 
			
		||||
            echo "aclfile /etc/redis/users.acl" >> /etc/redis/redis.conf
 | 
			
		||||
            print_status "Added ACL file configuration to redis.conf"
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        # Remove requirepass (incompatible with ACL)
 | 
			
		||||
        if grep -q "^requirepass" /etc/redis/redis.conf 2>/dev/null; then
 | 
			
		||||
            sed -i 's/^requirepass.*/# &/' /etc/redis/redis.conf
 | 
			
		||||
            print_status "Disabled requirepass (incompatible with ACL)"
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        # Create or update admin user in ACL file
 | 
			
		||||
        if grep -q "^user admin" /etc/redis/users.acl; then
 | 
			
		||||
            print_info "Admin user already exists in ACL, updating password..."
 | 
			
		||||
            # Remove old admin line and add new one
 | 
			
		||||
            sed -i '/^user admin/d' /etc/redis/users.acl
 | 
			
		||||
            echo "user admin on sanitize-payload >$REDIS_PASSWORD ~* &* +@all" >> /etc/redis/users.acl
 | 
			
		||||
            print_status "Updated admin user password"
 | 
			
		||||
        else
 | 
			
		||||
            echo "user admin on sanitize-payload >$REDIS_PASSWORD ~* &* +@all" >> /etc/redis/users.acl
 | 
			
		||||
            print_status "Created admin user in ACL"
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        # Restart Redis to apply ACL
 | 
			
		||||
        print_info "Restarting Redis to apply ACL configuration..."
 | 
			
		||||
        systemctl restart redis-server
 | 
			
		||||
        sleep 3
 | 
			
		||||
        sleep 5
 | 
			
		||||
        
 | 
			
		||||
        # Verify admin can connect
 | 
			
		||||
        local max_retries=3
 | 
			
		||||
        local retry=0
 | 
			
		||||
        local admin_works=false
 | 
			
		||||
        
 | 
			
		||||
        while [ $retry -lt $max_retries ]; do
 | 
			
		||||
            if redis-cli -h localhost -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ping >/dev/null 2>&1; then
 | 
			
		||||
                admin_works=true
 | 
			
		||||
                break
 | 
			
		||||
            fi
 | 
			
		||||
            print_info "Waiting for Redis to be ready... (attempt $((retry + 1))/$max_retries)"
 | 
			
		||||
            sleep 2
 | 
			
		||||
            retry=$((retry + 1))
 | 
			
		||||
        done
 | 
			
		||||
        
 | 
			
		||||
        if [ "$admin_works" = false ]; then
 | 
			
		||||
            print_error "Failed to verify admin connection after Redis restart"
 | 
			
		||||
            print_error "Redis ACL configuration may have issues"
 | 
			
		||||
            
 | 
			
		||||
            # Try to fix by disabling ACL and using requirepass instead
 | 
			
		||||
            print_warning "Attempting fallback: using requirepass instead of ACL..."
 | 
			
		||||
            sed -i 's/^aclfile/# aclfile/' /etc/redis/redis.conf
 | 
			
		||||
            sed -i "s/^# requirepass .*/requirepass $REDIS_PASSWORD/" /etc/redis/redis.conf
 | 
			
		||||
            if ! grep -q "^requirepass" /etc/redis/redis.conf; then
 | 
			
		||||
                echo "requirepass $REDIS_PASSWORD" >> /etc/redis/redis.conf
 | 
			
		||||
            fi
 | 
			
		||||
            systemctl restart redis-server
 | 
			
		||||
            sleep 3
 | 
			
		||||
            
 | 
			
		||||
            # Test requirepass
 | 
			
		||||
            if redis-cli -h localhost -p 6379 -a "$REDIS_PASSWORD" --no-auth-warning ping >/dev/null 2>&1; then
 | 
			
		||||
                print_status "Fallback successful - using requirepass authentication"
 | 
			
		||||
                # For requirepass, we don't use username
 | 
			
		||||
                REDIS_USER=""
 | 
			
		||||
            else
 | 
			
		||||
                print_error "Fallback also failed - Redis authentication is broken"
 | 
			
		||||
                return 1
 | 
			
		||||
            fi
 | 
			
		||||
        else
 | 
			
		||||
            print_status "Redis ACL configuration successful"
 | 
			
		||||
        fi
 | 
			
		||||
    elif [ -z "$admin_password" ]; then
 | 
			
		||||
        print_error "Redis requires authentication but no valid admin credentials found"
 | 
			
		||||
        print_error "Please check /etc/redis/users.acl or /etc/redis/redis.conf"
 | 
			
		||||
        print_info "Manual fix: Reset Redis authentication or provide admin credentials"
 | 
			
		||||
        return 1
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Create instance-specific Redis user
 | 
			
		||||
    print_info "Creating Redis user: $REDIS_USER"
 | 
			
		||||
    
 | 
			
		||||
    # Try to authenticate with admin (may already exist from another instance)
 | 
			
		||||
    local acl_result
 | 
			
		||||
    acl_result=$(redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL SETUSER "$REDIS_USER" on ">${REDIS_USER_PASSWORD}" ~* +@all 2>&1)
 | 
			
		||||
    
 | 
			
		||||
    if [ "$acl_result" = "OK" ] || echo "$acl_result" | grep -q "OK"; then
 | 
			
		||||
        print_status "Redis user created successfully"
 | 
			
		||||
        redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL SAVE > /dev/null 2>&1
 | 
			
		||||
    # Create instance-specific Redis user (only if using ACL)
 | 
			
		||||
    if [ -n "$REDIS_USER" ]; then
 | 
			
		||||
        print_info "Creating Redis user: $REDIS_USER"
 | 
			
		||||
        
 | 
			
		||||
        local acl_result=""
 | 
			
		||||
        if [ -n "$REDIS_PASSWORD" ]; then
 | 
			
		||||
            # Try to create user with ACL
 | 
			
		||||
            acl_result=$(redis-cli -h localhost -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL SETUSER "$REDIS_USER" on ">${REDIS_USER_PASSWORD}" ~* +@all 2>&1)
 | 
			
		||||
        else
 | 
			
		||||
            # Try without authentication (for legacy setups)
 | 
			
		||||
            acl_result=$(redis-cli -h localhost -p 6379 ACL SETUSER "$REDIS_USER" on ">${REDIS_USER_PASSWORD}" ~* +@all 2>&1)
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        if echo "$acl_result" | grep -q "OK"; then
 | 
			
		||||
            print_status "Redis user created successfully"
 | 
			
		||||
            
 | 
			
		||||
            # Save ACL users
 | 
			
		||||
            if [ -n "$REDIS_PASSWORD" ]; then
 | 
			
		||||
                redis-cli -h localhost -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL SAVE >/dev/null 2>&1
 | 
			
		||||
            else
 | 
			
		||||
                redis-cli -h localhost -p 6379 ACL SAVE >/dev/null 2>&1
 | 
			
		||||
            fi
 | 
			
		||||
            print_status "Redis ACL saved"
 | 
			
		||||
            
 | 
			
		||||
            # Verify user can connect
 | 
			
		||||
            if redis-cli -h localhost -p 6379 --user "$REDIS_USER" --pass "$REDIS_USER_PASSWORD" --no-auth-warning -n "$REDIS_DB" ping >/dev/null 2>&1; then
 | 
			
		||||
                print_status "Redis user verified and working"
 | 
			
		||||
            else
 | 
			
		||||
                print_warning "Redis user created but verification failed"
 | 
			
		||||
            fi
 | 
			
		||||
        else
 | 
			
		||||
            print_error "Failed to create Redis user: $acl_result"
 | 
			
		||||
            print_warning "Will use requirepass mode instead of per-user ACL"
 | 
			
		||||
            REDIS_USER=""
 | 
			
		||||
            REDIS_USER_PASSWORD="$REDIS_PASSWORD"
 | 
			
		||||
        fi
 | 
			
		||||
    else
 | 
			
		||||
        print_warning "Could not create Redis user with ACL, trying without authentication..."
 | 
			
		||||
        # Fallback for systems without ACL configured
 | 
			
		||||
        redis-cli -h 127.0.0.1 -p 6379 CONFIG SET requirepass "$REDIS_USER_PASSWORD" > /dev/null 2>&1 || true
 | 
			
		||||
        print_info "Using requirepass authentication (single password, no user-specific ACL)"
 | 
			
		||||
        REDIS_USER_PASSWORD="$REDIS_PASSWORD"
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Backup existing .env
 | 
			
		||||
@@ -2128,18 +2602,27 @@ update_redis_configuration() {
 | 
			
		||||
    
 | 
			
		||||
    # Add Redis configuration to .env
 | 
			
		||||
    print_info "Adding Redis configuration to .env..."
 | 
			
		||||
    
 | 
			
		||||
    # Use correct password variable
 | 
			
		||||
    local redis_pass_for_env="${REDIS_USER_PASSWORD:-$REDIS_PASSWORD}"
 | 
			
		||||
    
 | 
			
		||||
    cat >> "$instance_dir/backend/.env" << EOF
 | 
			
		||||
 | 
			
		||||
# Redis Configuration (added during update)
 | 
			
		||||
# Redis Configuration (added during update on $(date))
 | 
			
		||||
REDIS_HOST=localhost
 | 
			
		||||
REDIS_PORT=6379
 | 
			
		||||
REDIS_USER=$REDIS_USER
 | 
			
		||||
REDIS_PASSWORD=$REDIS_USER_PASSWORD
 | 
			
		||||
REDIS_PASSWORD=$redis_pass_for_env
 | 
			
		||||
REDIS_DB=$REDIS_DB
 | 
			
		||||
EOF
 | 
			
		||||
    
 | 
			
		||||
    print_status "Redis configuration added to .env"
 | 
			
		||||
    print_info "Redis User: $REDIS_USER"
 | 
			
		||||
    
 | 
			
		||||
    if [ -n "$REDIS_USER" ]; then
 | 
			
		||||
        print_info "Redis Mode: ACL with user '$REDIS_USER'"
 | 
			
		||||
    else
 | 
			
		||||
        print_info "Redis Mode: requirepass (legacy single-password auth)"
 | 
			
		||||
    fi
 | 
			
		||||
    print_info "Redis Database: $REDIS_DB"
 | 
			
		||||
    
 | 
			
		||||
    return 0
 | 
			
		||||
@@ -2448,6 +2931,8 @@ update_installation() {
 | 
			
		||||
    
 | 
			
		||||
    # Load existing .env to get database credentials
 | 
			
		||||
    if [ -f "$instance_dir/backend/.env" ]; then
 | 
			
		||||
        # Unset color variables before sourcing to prevent ANSI escape sequences from leaking into .env
 | 
			
		||||
        unset RED GREEN YELLOW BLUE NC
 | 
			
		||||
        source "$instance_dir/backend/.env"
 | 
			
		||||
        print_status "Loaded existing configuration"
 | 
			
		||||
        
 | 
			
		||||
@@ -2543,11 +3028,81 @@ update_installation() {
 | 
			
		||||
    print_info "Building frontend..."
 | 
			
		||||
    npm run build
 | 
			
		||||
    
 | 
			
		||||
    # Run database migrations and generate Prisma client
 | 
			
		||||
    # Run database migrations with self-healing
 | 
			
		||||
    print_info "Running database migrations..."
 | 
			
		||||
    cd "$instance_dir/backend"
 | 
			
		||||
    
 | 
			
		||||
    # Generate Prisma client first
 | 
			
		||||
    npx prisma generate
 | 
			
		||||
    npx prisma migrate deploy
 | 
			
		||||
    
 | 
			
		||||
    local max_attempts=3
 | 
			
		||||
    local attempt=1
 | 
			
		||||
    local migration_success=false
 | 
			
		||||
    
 | 
			
		||||
    while [ $attempt -le $max_attempts ]; do
 | 
			
		||||
        print_info "Migration attempt $attempt of $max_attempts..."
 | 
			
		||||
        
 | 
			
		||||
        # Try to run migrations
 | 
			
		||||
        local migrate_output
 | 
			
		||||
        migrate_output=$(npx prisma migrate deploy 2>&1 || echo "MIGRATION_FAILED")
 | 
			
		||||
        
 | 
			
		||||
        # Check if migration succeeded
 | 
			
		||||
        if ! echo "$migrate_output" | grep -q "MIGRATION_FAILED\|Error:\|P3009"; then
 | 
			
		||||
            print_status "Migrations completed successfully"
 | 
			
		||||
            migration_success=true
 | 
			
		||||
            break
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        # Check specifically for P3009 (failed migrations found)
 | 
			
		||||
        if echo "$migrate_output" | grep -q "P3009\|migrate found failed migrations"; then
 | 
			
		||||
            print_warning "Detected failed migrations (P3009 error)"
 | 
			
		||||
            
 | 
			
		||||
            # Extract the failed migration name if possible
 | 
			
		||||
            local failed_migration
 | 
			
		||||
            failed_migration=$(echo "$migrate_output" | grep -oP "The \`\K[^\`]+" | head -1 || echo "")
 | 
			
		||||
            
 | 
			
		||||
            if [ -n "$failed_migration" ]; then
 | 
			
		||||
                print_info "Failed migration identified: $failed_migration"
 | 
			
		||||
            fi
 | 
			
		||||
            
 | 
			
		||||
            # Attempt to fix failed migrations
 | 
			
		||||
            print_info "Attempting to self-heal migration issues..."
 | 
			
		||||
            if fix_failed_migrations "$DB_NAME" "$DB_USER" "$DB_PASS" "$DB_HOST"; then
 | 
			
		||||
                print_status "Migration issues resolved, retrying..."
 | 
			
		||||
                attempt=$((attempt + 1))
 | 
			
		||||
                sleep 2
 | 
			
		||||
                continue
 | 
			
		||||
            else
 | 
			
		||||
                print_error "Failed to resolve migration issues"
 | 
			
		||||
                print_warning "Attempting alternative resolution method..."
 | 
			
		||||
                
 | 
			
		||||
                # Alternative: Mark migration as completed if tables exist
 | 
			
		||||
                print_info "Checking if migration changes are already applied..."
 | 
			
		||||
                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 update script' WHERE migration_name = '$failed_migration' AND finished_at IS NULL;" >/dev/null 2>&1
 | 
			
		||||
                
 | 
			
		||||
                attempt=$((attempt + 1))
 | 
			
		||||
                sleep 2
 | 
			
		||||
                continue
 | 
			
		||||
            fi
 | 
			
		||||
        else
 | 
			
		||||
            # Other migration error
 | 
			
		||||
            print_error "Migration failed with error:"
 | 
			
		||||
            echo "$migrate_output" | grep -A 10 "Error:"
 | 
			
		||||
            
 | 
			
		||||
            # Show helpful information
 | 
			
		||||
            print_info "Migration status:"
 | 
			
		||||
            npx prisma migrate status 2>&1 || true
 | 
			
		||||
            break
 | 
			
		||||
        fi
 | 
			
		||||
    done
 | 
			
		||||
    
 | 
			
		||||
    if [ "$migration_success" = false ]; then
 | 
			
		||||
        print_error "Migrations failed after $max_attempts attempts"
 | 
			
		||||
        print_warning "The update will continue, but you may need to manually resolve migration issues"
 | 
			
		||||
        print_info "Check migrations: cd $instance_dir/backend && npx prisma migrate status"
 | 
			
		||||
        print_info "View failed migrations: PGPASSWORD=\"$DB_PASS\" psql -h \"$DB_HOST\" -U \"$DB_USER\" -d \"$DB_NAME\" -c \"SELECT * FROM _prisma_migrations WHERE finished_at IS NULL;\""
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Check and update Redis configuration if needed (for legacy installations)
 | 
			
		||||
    update_redis_configuration
 | 
			
		||||
@@ -2563,7 +3118,7 @@ update_installation() {
 | 
			
		||||
    systemctl start "$service_name"
 | 
			
		||||
    
 | 
			
		||||
    # Wait a moment and check status
 | 
			
		||||
    sleep 3
 | 
			
		||||
    sleep 5
 | 
			
		||||
    
 | 
			
		||||
    if systemctl is-active --quiet "$service_name"; then
 | 
			
		||||
        print_success "✅ Update completed successfully!"
 | 
			
		||||
@@ -2582,6 +3137,43 @@ update_installation() {
 | 
			
		||||
        echo ""
 | 
			
		||||
    else
 | 
			
		||||
        print_error "Service failed to start after update"
 | 
			
		||||
        echo ""
 | 
			
		||||
        
 | 
			
		||||
        # Show last 25 lines of service logs for debugging
 | 
			
		||||
        print_warning "=== Last 25 lines of service logs ==="
 | 
			
		||||
        journalctl -u "$service_name" -n 25 --no-pager || true
 | 
			
		||||
        print_warning "==================================="
 | 
			
		||||
        echo ""
 | 
			
		||||
        
 | 
			
		||||
        # Check for specific error patterns
 | 
			
		||||
        local logs=$(journalctl -u "$service_name" -n 50 --no-pager 2>/dev/null || echo "")
 | 
			
		||||
        
 | 
			
		||||
        if echo "$logs" | grep -q "WRONGPASS\|NOAUTH"; then
 | 
			
		||||
            print_error "❌ Detected Redis authentication error!"
 | 
			
		||||
            print_info "The service cannot authenticate with Redis."
 | 
			
		||||
            echo ""
 | 
			
		||||
            print_info "Current Redis configuration in .env:"
 | 
			
		||||
            grep "^REDIS_" "$instance_dir/backend/.env" || true
 | 
			
		||||
            echo ""
 | 
			
		||||
            print_info "Quick fix - Try reconfiguring Redis:"
 | 
			
		||||
            print_info "  1. Check Redis ACL users:"
 | 
			
		||||
            print_info "     redis-cli ACL LIST"
 | 
			
		||||
            echo ""
 | 
			
		||||
            print_info "  2. Test Redis connection with credentials from .env:"
 | 
			
		||||
            local test_user=$(grep "^REDIS_USER=" "$instance_dir/backend/.env" | cut -d'=' -f2)
 | 
			
		||||
            local test_pass=$(grep "^REDIS_PASSWORD=" "$instance_dir/backend/.env" | cut -d'=' -f2)
 | 
			
		||||
            local test_db=$(grep "^REDIS_DB=" "$instance_dir/backend/.env" | cut -d'=' -f2)
 | 
			
		||||
            print_info "     redis-cli --user $test_user --pass $test_pass -n ${test_db:-0} ping"
 | 
			
		||||
            echo ""
 | 
			
		||||
        elif echo "$logs" | grep -q "ECONNREFUSED"; then
 | 
			
		||||
            print_error "❌ Detected connection refused error!"
 | 
			
		||||
            print_info "Check if required services are running:"
 | 
			
		||||
            print_info "  systemctl status postgresql"
 | 
			
		||||
            print_info "  systemctl status redis-server"
 | 
			
		||||
        elif echo "$logs" | grep -q "Error:"; then
 | 
			
		||||
            print_error "❌ Application error detected in logs"
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        echo ""
 | 
			
		||||
        print_warning "ROLLBACK INSTRUCTIONS:"
 | 
			
		||||
        print_info "1. Restore code:"
 | 
			
		||||
@@ -2594,7 +3186,7 @@ update_installation() {
 | 
			
		||||
        print_info "3. Restart service:"
 | 
			
		||||
        print_info "   sudo systemctl start $service_name"
 | 
			
		||||
        echo ""
 | 
			
		||||
        print_info "Check logs: journalctl -u $service_name -f"
 | 
			
		||||
        print_info "View full logs: journalctl -u $service_name -f"
 | 
			
		||||
        exit 1
 | 
			
		||||
    fi
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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