mirror of
				https://github.com/9technologygroup/patchmon.net.git
				synced 2025-11-04 05:53:27 +00:00 
			
		
		
		
	Compare commits
	
		
			1 Commits
		
	
	
		
			main
			...
			renovate/d
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					9064ee589c | 
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							@@ -1,6 +1,6 @@
 | 
				
			|||||||
{
 | 
					{
 | 
				
			||||||
	"name": "patchmon-backend",
 | 
						"name": "patchmon-backend",
 | 
				
			||||||
	"version": "1.3.2",
 | 
						"version": "1.3.1",
 | 
				
			||||||
	"description": "Backend API for Linux Patch Monitoring System",
 | 
						"description": "Backend API for Linux Patch Monitoring System",
 | 
				
			||||||
	"license": "AGPL-3.0",
 | 
						"license": "AGPL-3.0",
 | 
				
			||||||
	"main": "src/server.js",
 | 
						"main": "src/server.js",
 | 
				
			||||||
@@ -22,7 +22,7 @@
 | 
				
			|||||||
		"bullmq": "^5.61.0",
 | 
							"bullmq": "^5.61.0",
 | 
				
			||||||
		"cookie-parser": "^1.4.7",
 | 
							"cookie-parser": "^1.4.7",
 | 
				
			||||||
		"cors": "^2.8.5",
 | 
							"cors": "^2.8.5",
 | 
				
			||||||
		"dotenv": "^16.4.7",
 | 
							"dotenv": "^17.0.0",
 | 
				
			||||||
		"express": "^4.21.2",
 | 
							"express": "^4.21.2",
 | 
				
			||||||
		"express-rate-limit": "^7.5.0",
 | 
							"express-rate-limit": "^7.5.0",
 | 
				
			||||||
		"express-validator": "^7.2.0",
 | 
							"express-validator": "^7.2.0",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,74 +0,0 @@
 | 
				
			|||||||
-- CreateTable
 | 
					 | 
				
			||||||
CREATE TABLE "docker_volumes" (
 | 
					 | 
				
			||||||
    "id" TEXT NOT NULL,
 | 
					 | 
				
			||||||
    "host_id" TEXT NOT NULL,
 | 
					 | 
				
			||||||
    "volume_id" TEXT NOT NULL,
 | 
					 | 
				
			||||||
    "name" TEXT NOT NULL,
 | 
					 | 
				
			||||||
    "driver" TEXT NOT NULL,
 | 
					 | 
				
			||||||
    "mountpoint" TEXT,
 | 
					 | 
				
			||||||
    "renderer" TEXT,
 | 
					 | 
				
			||||||
    "scope" TEXT NOT NULL DEFAULT 'local',
 | 
					 | 
				
			||||||
    "labels" JSONB,
 | 
					 | 
				
			||||||
    "options" JSONB,
 | 
					 | 
				
			||||||
    "size_bytes" BIGINT,
 | 
					 | 
				
			||||||
    "ref_count" INTEGER NOT NULL DEFAULT 0,
 | 
					 | 
				
			||||||
    "created_at" TIMESTAMP(3) NOT NULL,
 | 
					 | 
				
			||||||
    "updated_at" TIMESTAMP(3) NOT NULL,
 | 
					 | 
				
			||||||
    "last_checked" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    CONSTRAINT "docker_volumes_pkey" PRIMARY KEY ("id")
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
-- CreateTable
 | 
					 | 
				
			||||||
CREATE TABLE "docker_networks" (
 | 
					 | 
				
			||||||
    "id" TEXT NOT NULL,
 | 
					 | 
				
			||||||
    "host_id" TEXT NOT NULL,
 | 
					 | 
				
			||||||
    "network_id" TEXT NOT NULL,
 | 
					 | 
				
			||||||
    "name" TEXT NOT NULL,
 | 
					 | 
				
			||||||
    "driver" TEXT NOT NULL,
 | 
					 | 
				
			||||||
    "scope" TEXT NOT NULL DEFAULT 'local',
 | 
					 | 
				
			||||||
    "ipv6_enabled" BOOLEAN NOT NULL DEFAULT false,
 | 
					 | 
				
			||||||
    "internal" BOOLEAN NOT NULL DEFAULT false,
 | 
					 | 
				
			||||||
    "attachable" BOOLEAN NOT NULL DEFAULT true,
 | 
					 | 
				
			||||||
    "ingress" BOOLEAN NOT NULL DEFAULT false,
 | 
					 | 
				
			||||||
    "config_only" BOOLEAN NOT NULL DEFAULT false,
 | 
					 | 
				
			||||||
    "labels" JSONB,
 | 
					 | 
				
			||||||
    "ipam" JSONB,
 | 
					 | 
				
			||||||
    "container_count" INTEGER NOT NULL DEFAULT 0,
 | 
					 | 
				
			||||||
    "created_at" TIMESTAMP(3),
 | 
					 | 
				
			||||||
    "updated_at" TIMESTAMP(3) NOT NULL,
 | 
					 | 
				
			||||||
    "last_checked" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    CONSTRAINT "docker_networks_pkey" PRIMARY KEY ("id")
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
-- CreateIndex
 | 
					 | 
				
			||||||
CREATE INDEX "docker_volumes_host_id_idx" ON "docker_volumes"("host_id");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
-- CreateIndex
 | 
					 | 
				
			||||||
CREATE INDEX "docker_volumes_name_idx" ON "docker_volumes"("name");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
-- CreateIndex
 | 
					 | 
				
			||||||
CREATE INDEX "docker_volumes_driver_idx" ON "docker_volumes"("driver");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
-- CreateIndex
 | 
					 | 
				
			||||||
CREATE UNIQUE INDEX "docker_volumes_host_id_volume_id_key" ON "docker_volumes"("host_id", "volume_id");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
-- CreateIndex
 | 
					 | 
				
			||||||
CREATE INDEX "docker_networks_host_id_idx" ON "docker_networks"("host_id");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
-- CreateIndex
 | 
					 | 
				
			||||||
CREATE INDEX "docker_networks_name_idx" ON "docker_networks"("name");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
-- CreateIndex
 | 
					 | 
				
			||||||
CREATE INDEX "docker_networks_driver_idx" ON "docker_networks"("driver");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
-- CreateIndex
 | 
					 | 
				
			||||||
CREATE UNIQUE INDEX "docker_networks_host_id_network_id_key" ON "docker_networks"("host_id", "network_id");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
-- AddForeignKey
 | 
					 | 
				
			||||||
ALTER TABLE "docker_volumes" ADD CONSTRAINT "docker_volumes_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
-- AddForeignKey
 | 
					 | 
				
			||||||
ALTER TABLE "docker_networks" ADD CONSTRAINT "docker_networks_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@@ -1,6 +0,0 @@
 | 
				
			|||||||
-- AlterTable
 | 
					 | 
				
			||||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "theme_preference" VARCHAR(10) DEFAULT 'dark';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
-- AlterTable
 | 
					 | 
				
			||||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "color_theme" VARCHAR(50) DEFAULT 'cyber_blue';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@@ -114,8 +114,6 @@ model hosts {
 | 
				
			|||||||
  host_group_memberships  host_group_memberships[]
 | 
					  host_group_memberships  host_group_memberships[]
 | 
				
			||||||
  update_history          update_history[]
 | 
					  update_history          update_history[]
 | 
				
			||||||
  job_history             job_history[]
 | 
					  job_history             job_history[]
 | 
				
			||||||
  docker_volumes          docker_volumes[]
 | 
					 | 
				
			||||||
  docker_networks         docker_networks[]
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @@index([machine_id])
 | 
					  @@index([machine_id])
 | 
				
			||||||
  @@index([friendly_name])
 | 
					  @@index([friendly_name])
 | 
				
			||||||
@@ -196,6 +194,7 @@ model settings {
 | 
				
			|||||||
  metrics_enabled        Boolean   @default(true)
 | 
					  metrics_enabled        Boolean   @default(true)
 | 
				
			||||||
  metrics_anonymous_id   String?
 | 
					  metrics_anonymous_id   String?
 | 
				
			||||||
  metrics_last_sent      DateTime?
 | 
					  metrics_last_sent      DateTime?
 | 
				
			||||||
 | 
					  color_theme            String    @default("default")
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
model update_history {
 | 
					model update_history {
 | 
				
			||||||
@@ -227,8 +226,6 @@ model users {
 | 
				
			|||||||
  tfa_secret             String?
 | 
					  tfa_secret             String?
 | 
				
			||||||
  first_name             String?
 | 
					  first_name             String?
 | 
				
			||||||
  last_name              String?
 | 
					  last_name              String?
 | 
				
			||||||
  theme_preference       String?                  @default("dark")
 | 
					 | 
				
			||||||
  color_theme            String?                  @default("cyber_blue")
 | 
					 | 
				
			||||||
  dashboard_preferences  dashboard_preferences[]
 | 
					  dashboard_preferences  dashboard_preferences[]
 | 
				
			||||||
  user_sessions          user_sessions[]
 | 
					  user_sessions          user_sessions[]
 | 
				
			||||||
  auto_enrollment_tokens auto_enrollment_tokens[]
 | 
					  auto_enrollment_tokens auto_enrollment_tokens[]
 | 
				
			||||||
@@ -345,56 +342,6 @@ model docker_image_updates {
 | 
				
			|||||||
  @@index([is_security_update])
 | 
					  @@index([is_security_update])
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
model docker_volumes {
 | 
					 | 
				
			||||||
  id          String   @id
 | 
					 | 
				
			||||||
  host_id     String
 | 
					 | 
				
			||||||
  volume_id   String
 | 
					 | 
				
			||||||
  name        String
 | 
					 | 
				
			||||||
  driver      String
 | 
					 | 
				
			||||||
  mountpoint  String?
 | 
					 | 
				
			||||||
  renderer    String?
 | 
					 | 
				
			||||||
  scope       String   @default("local")
 | 
					 | 
				
			||||||
  labels      Json?
 | 
					 | 
				
			||||||
  options     Json?
 | 
					 | 
				
			||||||
  size_bytes  BigInt?
 | 
					 | 
				
			||||||
  ref_count   Int      @default(0)
 | 
					 | 
				
			||||||
  created_at  DateTime
 | 
					 | 
				
			||||||
  updated_at  DateTime
 | 
					 | 
				
			||||||
  last_checked DateTime @default(now())
 | 
					 | 
				
			||||||
  hosts       hosts    @relation(fields: [host_id], references: [id], onDelete: Cascade)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  @@unique([host_id, volume_id])
 | 
					 | 
				
			||||||
  @@index([host_id])
 | 
					 | 
				
			||||||
  @@index([name])
 | 
					 | 
				
			||||||
  @@index([driver])
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
model docker_networks {
 | 
					 | 
				
			||||||
  id             String   @id
 | 
					 | 
				
			||||||
  host_id        String
 | 
					 | 
				
			||||||
  network_id     String
 | 
					 | 
				
			||||||
  name           String
 | 
					 | 
				
			||||||
  driver         String
 | 
					 | 
				
			||||||
  scope          String   @default("local")
 | 
					 | 
				
			||||||
  ipv6_enabled   Boolean  @default(false)
 | 
					 | 
				
			||||||
  internal       Boolean  @default(false)
 | 
					 | 
				
			||||||
  attachable     Boolean  @default(true)
 | 
					 | 
				
			||||||
  ingress        Boolean  @default(false)
 | 
					 | 
				
			||||||
  config_only    Boolean  @default(false)
 | 
					 | 
				
			||||||
  labels         Json?
 | 
					 | 
				
			||||||
  ipam           Json?    // IPAM configuration (driver, config, options)
 | 
					 | 
				
			||||||
  container_count Int     @default(0)
 | 
					 | 
				
			||||||
  created_at     DateTime?
 | 
					 | 
				
			||||||
  updated_at     DateTime
 | 
					 | 
				
			||||||
  last_checked   DateTime @default(now())
 | 
					 | 
				
			||||||
  hosts          hosts    @relation(fields: [host_id], references: [id], onDelete: Cascade)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  @@unique([host_id, network_id])
 | 
					 | 
				
			||||||
  @@index([host_id])
 | 
					 | 
				
			||||||
  @@index([name])
 | 
					 | 
				
			||||||
  @@index([driver])
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
model job_history {
 | 
					model job_history {
 | 
				
			||||||
  id            String   @id
 | 
					  id            String   @id
 | 
				
			||||||
  job_id        String
 | 
					  job_id        String
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,7 +17,6 @@ const {
 | 
				
			|||||||
	refresh_access_token,
 | 
						refresh_access_token,
 | 
				
			||||||
	revoke_session,
 | 
						revoke_session,
 | 
				
			||||||
	revoke_all_user_sessions,
 | 
						revoke_all_user_sessions,
 | 
				
			||||||
	generate_device_fingerprint,
 | 
					 | 
				
			||||||
} = require("../utils/session_manager");
 | 
					} = require("../utils/session_manager");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const router = express.Router();
 | 
					const router = express.Router();
 | 
				
			||||||
@@ -789,39 +788,11 @@ router.post(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
			// Check if TFA is enabled
 | 
								// Check if TFA is enabled
 | 
				
			||||||
			if (user.tfa_enabled) {
 | 
								if (user.tfa_enabled) {
 | 
				
			||||||
				// Get device fingerprint from X-Device-ID header
 | 
									return res.status(200).json({
 | 
				
			||||||
				const device_fingerprint = generate_device_fingerprint(req);
 | 
										message: "TFA verification required",
 | 
				
			||||||
 | 
										requiresTfa: true,
 | 
				
			||||||
				// Check if this device has a valid TFA bypass
 | 
										username: user.username,
 | 
				
			||||||
				if (device_fingerprint) {
 | 
									});
 | 
				
			||||||
					const remembered_session = await prisma.user_sessions.findFirst({
 | 
					 | 
				
			||||||
						where: {
 | 
					 | 
				
			||||||
							user_id: user.id,
 | 
					 | 
				
			||||||
							device_fingerprint: device_fingerprint,
 | 
					 | 
				
			||||||
							tfa_remember_me: true,
 | 
					 | 
				
			||||||
							tfa_bypass_until: { gt: new Date() }, // Bypass still valid
 | 
					 | 
				
			||||||
						},
 | 
					 | 
				
			||||||
					});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
					if (remembered_session) {
 | 
					 | 
				
			||||||
						// Device is remembered and bypass is still valid - skip TFA
 | 
					 | 
				
			||||||
						// Continue with login below
 | 
					 | 
				
			||||||
					} else {
 | 
					 | 
				
			||||||
						// No valid bypass for this device - require TFA
 | 
					 | 
				
			||||||
						return res.status(200).json({
 | 
					 | 
				
			||||||
							message: "TFA verification required",
 | 
					 | 
				
			||||||
							requiresTfa: true,
 | 
					 | 
				
			||||||
							username: user.username,
 | 
					 | 
				
			||||||
						});
 | 
					 | 
				
			||||||
					}
 | 
					 | 
				
			||||||
				} else {
 | 
					 | 
				
			||||||
					// No device ID provided - require TFA
 | 
					 | 
				
			||||||
					return res.status(200).json({
 | 
					 | 
				
			||||||
						message: "TFA verification required",
 | 
					 | 
				
			||||||
						requiresTfa: true,
 | 
					 | 
				
			||||||
						username: user.username,
 | 
					 | 
				
			||||||
					});
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			// Update last login
 | 
								// Update last login
 | 
				
			||||||
@@ -836,13 +807,7 @@ router.post(
 | 
				
			|||||||
			// Create session with access and refresh tokens
 | 
								// Create session with access and refresh tokens
 | 
				
			||||||
			const ip_address = req.ip || req.connection.remoteAddress;
 | 
								const ip_address = req.ip || req.connection.remoteAddress;
 | 
				
			||||||
			const user_agent = req.get("user-agent");
 | 
								const user_agent = req.get("user-agent");
 | 
				
			||||||
			const session = await create_session(
 | 
								const session = await create_session(user.id, ip_address, user_agent);
 | 
				
			||||||
				user.id,
 | 
					 | 
				
			||||||
				ip_address,
 | 
					 | 
				
			||||||
				user_agent,
 | 
					 | 
				
			||||||
				false,
 | 
					 | 
				
			||||||
				req,
 | 
					 | 
				
			||||||
			);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
			res.json({
 | 
								res.json({
 | 
				
			||||||
				message: "Login successful",
 | 
									message: "Login successful",
 | 
				
			||||||
@@ -860,9 +825,6 @@ router.post(
 | 
				
			|||||||
					last_login: user.last_login,
 | 
										last_login: user.last_login,
 | 
				
			||||||
					created_at: user.created_at,
 | 
										created_at: user.created_at,
 | 
				
			||||||
					updated_at: user.updated_at,
 | 
										updated_at: user.updated_at,
 | 
				
			||||||
					// Include user preferences so they're available immediately after login
 | 
					 | 
				
			||||||
					theme_preference: user.theme_preference,
 | 
					 | 
				
			||||||
					color_theme: user.color_theme,
 | 
					 | 
				
			||||||
				},
 | 
									},
 | 
				
			||||||
			});
 | 
								});
 | 
				
			||||||
		} catch (error) {
 | 
							} catch (error) {
 | 
				
			||||||
@@ -879,10 +841,8 @@ router.post(
 | 
				
			|||||||
		body("username").notEmpty().withMessage("Username is required"),
 | 
							body("username").notEmpty().withMessage("Username is required"),
 | 
				
			||||||
		body("token")
 | 
							body("token")
 | 
				
			||||||
			.isLength({ min: 6, max: 6 })
 | 
								.isLength({ min: 6, max: 6 })
 | 
				
			||||||
			.withMessage("Token must be 6 characters"),
 | 
								.withMessage("Token must be 6 digits"),
 | 
				
			||||||
		body("token")
 | 
							body("token").isNumeric().withMessage("Token must contain only numbers"),
 | 
				
			||||||
			.matches(/^[A-Z0-9]{6}$/)
 | 
					 | 
				
			||||||
			.withMessage("Token must be 6 alphanumeric characters"),
 | 
					 | 
				
			||||||
		body("remember_me")
 | 
							body("remember_me")
 | 
				
			||||||
			.optional()
 | 
								.optional()
 | 
				
			||||||
			.isBoolean()
 | 
								.isBoolean()
 | 
				
			||||||
@@ -955,24 +915,10 @@ router.post(
 | 
				
			|||||||
				return res.status(401).json({ error: "Invalid verification code" });
 | 
									return res.status(401).json({ error: "Invalid verification code" });
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			// Update last login and fetch complete user data
 | 
								// Update last login
 | 
				
			||||||
			const updatedUser = await prisma.users.update({
 | 
								await prisma.users.update({
 | 
				
			||||||
				where: { id: user.id },
 | 
									where: { id: user.id },
 | 
				
			||||||
				data: { last_login: new Date() },
 | 
									data: { last_login: new Date() },
 | 
				
			||||||
				select: {
 | 
					 | 
				
			||||||
					id: true,
 | 
					 | 
				
			||||||
					username: true,
 | 
					 | 
				
			||||||
					email: true,
 | 
					 | 
				
			||||||
					first_name: true,
 | 
					 | 
				
			||||||
					last_name: true,
 | 
					 | 
				
			||||||
					role: true,
 | 
					 | 
				
			||||||
					is_active: true,
 | 
					 | 
				
			||||||
					last_login: true,
 | 
					 | 
				
			||||||
					created_at: true,
 | 
					 | 
				
			||||||
					updated_at: true,
 | 
					 | 
				
			||||||
					theme_preference: true,
 | 
					 | 
				
			||||||
					color_theme: true,
 | 
					 | 
				
			||||||
				},
 | 
					 | 
				
			||||||
			});
 | 
								});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			// Create session with access and refresh tokens
 | 
								// Create session with access and refresh tokens
 | 
				
			||||||
@@ -992,7 +938,14 @@ router.post(
 | 
				
			|||||||
				refresh_token: session.refresh_token,
 | 
									refresh_token: session.refresh_token,
 | 
				
			||||||
				expires_at: session.expires_at,
 | 
									expires_at: session.expires_at,
 | 
				
			||||||
				tfa_bypass_until: session.tfa_bypass_until,
 | 
									tfa_bypass_until: session.tfa_bypass_until,
 | 
				
			||||||
				user: updatedUser,
 | 
									user: {
 | 
				
			||||||
 | 
										id: user.id,
 | 
				
			||||||
 | 
										username: user.username,
 | 
				
			||||||
 | 
										email: user.email,
 | 
				
			||||||
 | 
										first_name: user.first_name,
 | 
				
			||||||
 | 
										last_name: user.last_name,
 | 
				
			||||||
 | 
										role: user.role,
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
			});
 | 
								});
 | 
				
			||||||
		} catch (error) {
 | 
							} catch (error) {
 | 
				
			||||||
			console.error("TFA verification error:", error);
 | 
								console.error("TFA verification error:", error);
 | 
				
			||||||
@@ -1024,27 +977,13 @@ router.put(
 | 
				
			|||||||
			.withMessage("Username must be at least 3 characters"),
 | 
								.withMessage("Username must be at least 3 characters"),
 | 
				
			||||||
		body("email").optional().isEmail().withMessage("Valid email is required"),
 | 
							body("email").optional().isEmail().withMessage("Valid email is required"),
 | 
				
			||||||
		body("first_name")
 | 
							body("first_name")
 | 
				
			||||||
			.optional({ nullable: true, checkFalsy: true })
 | 
								.optional()
 | 
				
			||||||
			.custom((value) => {
 | 
								.isLength({ min: 1 })
 | 
				
			||||||
				// Allow null, undefined, or empty string to clear the field
 | 
								.withMessage("First name must be at least 1 character"),
 | 
				
			||||||
				if (value === null || value === undefined || value === "") {
 | 
					 | 
				
			||||||
					return true;
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
				// If provided, must be at least 1 character after trimming
 | 
					 | 
				
			||||||
				return typeof value === "string" && value.trim().length >= 1;
 | 
					 | 
				
			||||||
			})
 | 
					 | 
				
			||||||
			.withMessage("First name must be at least 1 character if provided"),
 | 
					 | 
				
			||||||
		body("last_name")
 | 
							body("last_name")
 | 
				
			||||||
			.optional({ nullable: true, checkFalsy: true })
 | 
								.optional()
 | 
				
			||||||
			.custom((value) => {
 | 
								.isLength({ min: 1 })
 | 
				
			||||||
				// Allow null, undefined, or empty string to clear the field
 | 
								.withMessage("Last name must be at least 1 character"),
 | 
				
			||||||
				if (value === null || value === undefined || value === "") {
 | 
					 | 
				
			||||||
					return true;
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
				// If provided, must be at least 1 character after trimming
 | 
					 | 
				
			||||||
				return typeof value === "string" && value.trim().length >= 1;
 | 
					 | 
				
			||||||
			})
 | 
					 | 
				
			||||||
			.withMessage("Last name must be at least 1 character if provided"),
 | 
					 | 
				
			||||||
	],
 | 
						],
 | 
				
			||||||
	async (req, res) => {
 | 
						async (req, res) => {
 | 
				
			||||||
		try {
 | 
							try {
 | 
				
			||||||
@@ -1054,27 +993,12 @@ router.put(
 | 
				
			|||||||
			}
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			const { username, email, first_name, last_name } = req.body;
 | 
								const { username, email, first_name, last_name } = req.body;
 | 
				
			||||||
			const updateData = {
 | 
								const updateData = {};
 | 
				
			||||||
				updated_at: new Date(),
 | 
					 | 
				
			||||||
			};
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
			// Handle all fields consistently - trim and update if provided
 | 
								if (username) updateData.username = username;
 | 
				
			||||||
			if (username) updateData.username = username.trim();
 | 
								if (email) updateData.email = email;
 | 
				
			||||||
			if (email) updateData.email = email.trim();
 | 
								if (first_name !== undefined) updateData.first_name = first_name || null;
 | 
				
			||||||
			if (first_name !== undefined) {
 | 
								if (last_name !== undefined) updateData.last_name = last_name || null;
 | 
				
			||||||
				// Allow null or empty string to clear the field, otherwise trim
 | 
					 | 
				
			||||||
				updateData.first_name =
 | 
					 | 
				
			||||||
					first_name === "" || first_name === null
 | 
					 | 
				
			||||||
						? null
 | 
					 | 
				
			||||||
						: first_name.trim() || null;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			if (last_name !== undefined) {
 | 
					 | 
				
			||||||
				// Allow null or empty string to clear the field, otherwise trim
 | 
					 | 
				
			||||||
				updateData.last_name =
 | 
					 | 
				
			||||||
					last_name === "" || last_name === null
 | 
					 | 
				
			||||||
						? null
 | 
					 | 
				
			||||||
						: last_name.trim() || null;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
			// Check if username/email already exists (excluding current user)
 | 
								// Check if username/email already exists (excluding current user)
 | 
				
			||||||
			if (username || email) {
 | 
								if (username || email) {
 | 
				
			||||||
@@ -1099,7 +1023,6 @@ router.put(
 | 
				
			|||||||
				}
 | 
									}
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			// Update user with explicit commit
 | 
					 | 
				
			||||||
			const updatedUser = await prisma.users.update({
 | 
								const updatedUser = await prisma.users.update({
 | 
				
			||||||
				where: { id: req.user.id },
 | 
									where: { id: req.user.id },
 | 
				
			||||||
				data: updateData,
 | 
									data: updateData,
 | 
				
			||||||
@@ -1116,29 +1039,9 @@ router.put(
 | 
				
			|||||||
				},
 | 
									},
 | 
				
			||||||
			});
 | 
								});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			// Explicitly refresh user data from database to ensure we return latest data
 | 
					 | 
				
			||||||
			// This ensures consistency especially in high-concurrency scenarios
 | 
					 | 
				
			||||||
			const freshUser = await prisma.users.findUnique({
 | 
					 | 
				
			||||||
				where: { id: req.user.id },
 | 
					 | 
				
			||||||
				select: {
 | 
					 | 
				
			||||||
					id: true,
 | 
					 | 
				
			||||||
					username: true,
 | 
					 | 
				
			||||||
					email: true,
 | 
					 | 
				
			||||||
					first_name: true,
 | 
					 | 
				
			||||||
					last_name: true,
 | 
					 | 
				
			||||||
					role: true,
 | 
					 | 
				
			||||||
					is_active: true,
 | 
					 | 
				
			||||||
					last_login: true,
 | 
					 | 
				
			||||||
					updated_at: true,
 | 
					 | 
				
			||||||
				},
 | 
					 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			// Use fresh data if available, otherwise fallback to updatedUser
 | 
					 | 
				
			||||||
			const responseUser = freshUser || updatedUser;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			res.json({
 | 
								res.json({
 | 
				
			||||||
				message: "Profile updated successfully",
 | 
									message: "Profile updated successfully",
 | 
				
			||||||
				user: responseUser,
 | 
									user: updatedUser,
 | 
				
			||||||
			});
 | 
								});
 | 
				
			||||||
		} catch (error) {
 | 
							} catch (error) {
 | 
				
			||||||
			console.error("Update profile error:", error);
 | 
								console.error("Update profile error:", error);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -573,7 +573,6 @@ router.post("/collect", async (req, res) => {
 | 
				
			|||||||
							image_id: containerData.image_id || "unknown",
 | 
												image_id: containerData.image_id || "unknown",
 | 
				
			||||||
							source: containerData.image_source || "docker-hub",
 | 
												source: containerData.image_source || "docker-hub",
 | 
				
			||||||
							created_at: parseDate(containerData.created_at),
 | 
												created_at: parseDate(containerData.created_at),
 | 
				
			||||||
							last_checked: now,
 | 
					 | 
				
			||||||
							updated_at: now,
 | 
												updated_at: now,
 | 
				
			||||||
						},
 | 
											},
 | 
				
			||||||
					});
 | 
										});
 | 
				
			||||||
@@ -823,7 +822,6 @@ router.post("/../integrations/docker", async (req, res) => {
 | 
				
			|||||||
							image_id: containerData.image_id || "unknown",
 | 
												image_id: containerData.image_id || "unknown",
 | 
				
			||||||
							source: containerData.image_source || "docker-hub",
 | 
												source: containerData.image_source || "docker-hub",
 | 
				
			||||||
							created_at: parseDate(containerData.created_at),
 | 
												created_at: parseDate(containerData.created_at),
 | 
				
			||||||
							last_checked: now,
 | 
					 | 
				
			||||||
							updated_at: now,
 | 
												updated_at: now,
 | 
				
			||||||
						},
 | 
											},
 | 
				
			||||||
					});
 | 
										});
 | 
				
			||||||
@@ -878,12 +876,6 @@ router.post("/../integrations/docker", async (req, res) => {
 | 
				
			|||||||
		if (images && Array.isArray(images)) {
 | 
							if (images && Array.isArray(images)) {
 | 
				
			||||||
			console.log(`[Docker Integration] Processing ${images.length} images`);
 | 
								console.log(`[Docker Integration] Processing ${images.length} images`);
 | 
				
			||||||
			for (const imageData of images) {
 | 
								for (const imageData of images) {
 | 
				
			||||||
				// If image has no digest, it's likely locally built - override source to "local"
 | 
					 | 
				
			||||||
				const imageSource =
 | 
					 | 
				
			||||||
					!imageData.digest || imageData.digest.trim() === ""
 | 
					 | 
				
			||||||
						? "local"
 | 
					 | 
				
			||||||
						: imageData.source || "docker-hub";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				await prisma.docker_images.upsert({
 | 
									await prisma.docker_images.upsert({
 | 
				
			||||||
					where: {
 | 
										where: {
 | 
				
			||||||
						repository_tag_image_id: {
 | 
											repository_tag_image_id: {
 | 
				
			||||||
@@ -897,7 +889,6 @@ router.post("/../integrations/docker", async (req, res) => {
 | 
				
			|||||||
							? BigInt(imageData.size_bytes)
 | 
												? BigInt(imageData.size_bytes)
 | 
				
			||||||
							: null,
 | 
												: null,
 | 
				
			||||||
						digest: imageData.digest || null,
 | 
											digest: imageData.digest || null,
 | 
				
			||||||
						source: imageSource, // Update source in case it changed
 | 
					 | 
				
			||||||
						last_checked: now,
 | 
											last_checked: now,
 | 
				
			||||||
						updated_at: now,
 | 
											updated_at: now,
 | 
				
			||||||
					},
 | 
										},
 | 
				
			||||||
@@ -910,9 +901,8 @@ router.post("/../integrations/docker", async (req, res) => {
 | 
				
			|||||||
						size_bytes: imageData.size_bytes
 | 
											size_bytes: imageData.size_bytes
 | 
				
			||||||
							? BigInt(imageData.size_bytes)
 | 
												? BigInt(imageData.size_bytes)
 | 
				
			||||||
							: null,
 | 
												: null,
 | 
				
			||||||
						source: imageSource,
 | 
											source: imageData.source || "docker-hub",
 | 
				
			||||||
						created_at: parseDate(imageData.created_at),
 | 
											created_at: parseDate(imageData.created_at),
 | 
				
			||||||
						last_checked: now,
 | 
					 | 
				
			||||||
						updated_at: now,
 | 
											updated_at: now,
 | 
				
			||||||
					},
 | 
										},
 | 
				
			||||||
				});
 | 
									});
 | 
				
			||||||
@@ -1072,172 +1062,6 @@ router.delete("/images/:id", authenticateToken, async (req, res) => {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// GET /api/v1/docker/volumes - Get all volumes with filters
 | 
					 | 
				
			||||||
router.get("/volumes", authenticateToken, async (req, res) => {
 | 
					 | 
				
			||||||
	try {
 | 
					 | 
				
			||||||
		const { driver, search, page = 1, limit = 50 } = req.query;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		const where = {};
 | 
					 | 
				
			||||||
		if (driver) where.driver = driver;
 | 
					 | 
				
			||||||
		if (search) {
 | 
					 | 
				
			||||||
			where.OR = [{ name: { contains: search, mode: "insensitive" } }];
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
 | 
					 | 
				
			||||||
		const take = parseInt(limit, 10);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		const [volumes, total] = await Promise.all([
 | 
					 | 
				
			||||||
			prisma.docker_volumes.findMany({
 | 
					 | 
				
			||||||
				where,
 | 
					 | 
				
			||||||
				include: {
 | 
					 | 
				
			||||||
					hosts: {
 | 
					 | 
				
			||||||
						select: {
 | 
					 | 
				
			||||||
							id: true,
 | 
					 | 
				
			||||||
							friendly_name: true,
 | 
					 | 
				
			||||||
							hostname: true,
 | 
					 | 
				
			||||||
							ip: true,
 | 
					 | 
				
			||||||
						},
 | 
					 | 
				
			||||||
					},
 | 
					 | 
				
			||||||
				},
 | 
					 | 
				
			||||||
				orderBy: { updated_at: "desc" },
 | 
					 | 
				
			||||||
				skip,
 | 
					 | 
				
			||||||
				take,
 | 
					 | 
				
			||||||
			}),
 | 
					 | 
				
			||||||
			prisma.docker_volumes.count({ where }),
 | 
					 | 
				
			||||||
		]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		res.json(
 | 
					 | 
				
			||||||
			convertBigIntToString({
 | 
					 | 
				
			||||||
				volumes,
 | 
					 | 
				
			||||||
				pagination: {
 | 
					 | 
				
			||||||
					page: parseInt(page, 10),
 | 
					 | 
				
			||||||
					limit: parseInt(limit, 10),
 | 
					 | 
				
			||||||
					total,
 | 
					 | 
				
			||||||
					totalPages: Math.ceil(total / parseInt(limit, 10)),
 | 
					 | 
				
			||||||
				},
 | 
					 | 
				
			||||||
			}),
 | 
					 | 
				
			||||||
		);
 | 
					 | 
				
			||||||
	} catch (error) {
 | 
					 | 
				
			||||||
		console.error("Error fetching volumes:", error);
 | 
					 | 
				
			||||||
		res.status(500).json({ error: "Failed to fetch volumes" });
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// GET /api/v1/docker/volumes/:id - Get volume detail
 | 
					 | 
				
			||||||
router.get("/volumes/:id", authenticateToken, async (req, res) => {
 | 
					 | 
				
			||||||
	try {
 | 
					 | 
				
			||||||
		const { id } = req.params;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		const volume = await prisma.docker_volumes.findUnique({
 | 
					 | 
				
			||||||
			where: { id },
 | 
					 | 
				
			||||||
			include: {
 | 
					 | 
				
			||||||
				hosts: {
 | 
					 | 
				
			||||||
					select: {
 | 
					 | 
				
			||||||
						id: true,
 | 
					 | 
				
			||||||
						friendly_name: true,
 | 
					 | 
				
			||||||
						hostname: true,
 | 
					 | 
				
			||||||
						ip: true,
 | 
					 | 
				
			||||||
						os_type: true,
 | 
					 | 
				
			||||||
						os_version: true,
 | 
					 | 
				
			||||||
					},
 | 
					 | 
				
			||||||
				},
 | 
					 | 
				
			||||||
			},
 | 
					 | 
				
			||||||
		});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		if (!volume) {
 | 
					 | 
				
			||||||
			return res.status(404).json({ error: "Volume not found" });
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		res.json(convertBigIntToString({ volume }));
 | 
					 | 
				
			||||||
	} catch (error) {
 | 
					 | 
				
			||||||
		console.error("Error fetching volume detail:", error);
 | 
					 | 
				
			||||||
		res.status(500).json({ error: "Failed to fetch volume detail" });
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// GET /api/v1/docker/networks - Get all networks with filters
 | 
					 | 
				
			||||||
router.get("/networks", authenticateToken, async (req, res) => {
 | 
					 | 
				
			||||||
	try {
 | 
					 | 
				
			||||||
		const { driver, search, page = 1, limit = 50 } = req.query;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		const where = {};
 | 
					 | 
				
			||||||
		if (driver) where.driver = driver;
 | 
					 | 
				
			||||||
		if (search) {
 | 
					 | 
				
			||||||
			where.OR = [{ name: { contains: search, mode: "insensitive" } }];
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
 | 
					 | 
				
			||||||
		const take = parseInt(limit, 10);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		const [networks, total] = await Promise.all([
 | 
					 | 
				
			||||||
			prisma.docker_networks.findMany({
 | 
					 | 
				
			||||||
				where,
 | 
					 | 
				
			||||||
				include: {
 | 
					 | 
				
			||||||
					hosts: {
 | 
					 | 
				
			||||||
						select: {
 | 
					 | 
				
			||||||
							id: true,
 | 
					 | 
				
			||||||
							friendly_name: true,
 | 
					 | 
				
			||||||
							hostname: true,
 | 
					 | 
				
			||||||
							ip: true,
 | 
					 | 
				
			||||||
						},
 | 
					 | 
				
			||||||
					},
 | 
					 | 
				
			||||||
				},
 | 
					 | 
				
			||||||
				orderBy: { updated_at: "desc" },
 | 
					 | 
				
			||||||
				skip,
 | 
					 | 
				
			||||||
				take,
 | 
					 | 
				
			||||||
			}),
 | 
					 | 
				
			||||||
			prisma.docker_networks.count({ where }),
 | 
					 | 
				
			||||||
		]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		res.json(
 | 
					 | 
				
			||||||
			convertBigIntToString({
 | 
					 | 
				
			||||||
				networks,
 | 
					 | 
				
			||||||
				pagination: {
 | 
					 | 
				
			||||||
					page: parseInt(page, 10),
 | 
					 | 
				
			||||||
					limit: parseInt(limit, 10),
 | 
					 | 
				
			||||||
					total,
 | 
					 | 
				
			||||||
					totalPages: Math.ceil(total / parseInt(limit, 10)),
 | 
					 | 
				
			||||||
				},
 | 
					 | 
				
			||||||
			}),
 | 
					 | 
				
			||||||
		);
 | 
					 | 
				
			||||||
	} catch (error) {
 | 
					 | 
				
			||||||
		console.error("Error fetching networks:", error);
 | 
					 | 
				
			||||||
		res.status(500).json({ error: "Failed to fetch networks" });
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// GET /api/v1/docker/networks/:id - Get network detail
 | 
					 | 
				
			||||||
router.get("/networks/:id", authenticateToken, async (req, res) => {
 | 
					 | 
				
			||||||
	try {
 | 
					 | 
				
			||||||
		const { id } = req.params;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		const network = await prisma.docker_networks.findUnique({
 | 
					 | 
				
			||||||
			where: { id },
 | 
					 | 
				
			||||||
			include: {
 | 
					 | 
				
			||||||
				hosts: {
 | 
					 | 
				
			||||||
					select: {
 | 
					 | 
				
			||||||
						id: true,
 | 
					 | 
				
			||||||
						friendly_name: true,
 | 
					 | 
				
			||||||
						hostname: true,
 | 
					 | 
				
			||||||
						ip: true,
 | 
					 | 
				
			||||||
						os_type: true,
 | 
					 | 
				
			||||||
						os_version: true,
 | 
					 | 
				
			||||||
					},
 | 
					 | 
				
			||||||
				},
 | 
					 | 
				
			||||||
			},
 | 
					 | 
				
			||||||
		});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		if (!network) {
 | 
					 | 
				
			||||||
			return res.status(404).json({ error: "Network not found" });
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		res.json(convertBigIntToString({ network }));
 | 
					 | 
				
			||||||
	} catch (error) {
 | 
					 | 
				
			||||||
		console.error("Error fetching network detail:", error);
 | 
					 | 
				
			||||||
		res.status(500).json({ error: "Failed to fetch network detail" });
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// GET /api/v1/docker/agent - Serve the Docker agent installation script
 | 
					// GET /api/v1/docker/agent - Serve the Docker agent installation script
 | 
				
			||||||
router.get("/agent", async (_req, res) => {
 | 
					router.get("/agent", async (_req, res) => {
 | 
				
			||||||
	try {
 | 
						try {
 | 
				
			||||||
@@ -1269,66 +1093,4 @@ router.get("/agent", async (_req, res) => {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// DELETE /api/v1/docker/volumes/:id - Delete a volume
 | 
					 | 
				
			||||||
router.delete("/volumes/:id", authenticateToken, async (req, res) => {
 | 
					 | 
				
			||||||
	try {
 | 
					 | 
				
			||||||
		const { id } = req.params;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		// Check if volume exists
 | 
					 | 
				
			||||||
		const volume = await prisma.docker_volumes.findUnique({
 | 
					 | 
				
			||||||
			where: { id },
 | 
					 | 
				
			||||||
		});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		if (!volume) {
 | 
					 | 
				
			||||||
			return res.status(404).json({ error: "Volume not found" });
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		// Delete the volume
 | 
					 | 
				
			||||||
		await prisma.docker_volumes.delete({
 | 
					 | 
				
			||||||
			where: { id },
 | 
					 | 
				
			||||||
		});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		console.log(`🗑️  Deleted volume: ${volume.name} (${id})`);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		res.json({
 | 
					 | 
				
			||||||
			success: true,
 | 
					 | 
				
			||||||
			message: `Volume ${volume.name} deleted successfully`,
 | 
					 | 
				
			||||||
		});
 | 
					 | 
				
			||||||
	} catch (error) {
 | 
					 | 
				
			||||||
		console.error("Error deleting volume:", error);
 | 
					 | 
				
			||||||
		res.status(500).json({ error: "Failed to delete volume" });
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// DELETE /api/v1/docker/networks/:id - Delete a network
 | 
					 | 
				
			||||||
router.delete("/networks/:id", authenticateToken, async (req, res) => {
 | 
					 | 
				
			||||||
	try {
 | 
					 | 
				
			||||||
		const { id } = req.params;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		// Check if network exists
 | 
					 | 
				
			||||||
		const network = await prisma.docker_networks.findUnique({
 | 
					 | 
				
			||||||
			where: { id },
 | 
					 | 
				
			||||||
		});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		if (!network) {
 | 
					 | 
				
			||||||
			return res.status(404).json({ error: "Network not found" });
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		// Delete the network
 | 
					 | 
				
			||||||
		await prisma.docker_networks.delete({
 | 
					 | 
				
			||||||
			where: { id },
 | 
					 | 
				
			||||||
		});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		console.log(`🗑️  Deleted network: ${network.name} (${id})`);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		res.json({
 | 
					 | 
				
			||||||
			success: true,
 | 
					 | 
				
			||||||
			message: `Network ${network.name} deleted successfully`,
 | 
					 | 
				
			||||||
		});
 | 
					 | 
				
			||||||
	} catch (error) {
 | 
					 | 
				
			||||||
		console.error("Error deleting network:", error);
 | 
					 | 
				
			||||||
		res.status(500).json({ error: "Failed to delete network" });
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
module.exports = router;
 | 
					module.exports = router;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -24,15 +24,7 @@ router.get("/", authenticateToken, async (_req, res) => {
 | 
				
			|||||||
			},
 | 
								},
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Transform the count field to match frontend expectations
 | 
							res.json(hostGroups);
 | 
				
			||||||
		const transformedGroups = hostGroups.map((group) => ({
 | 
					 | 
				
			||||||
			...group,
 | 
					 | 
				
			||||||
			_count: {
 | 
					 | 
				
			||||||
				hosts: group._count.host_group_memberships,
 | 
					 | 
				
			||||||
			},
 | 
					 | 
				
			||||||
		}));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		res.json(transformedGroups);
 | 
					 | 
				
			||||||
	} catch (error) {
 | 
						} catch (error) {
 | 
				
			||||||
		console.error("Error fetching host groups:", error);
 | 
							console.error("Error fetching host groups:", error);
 | 
				
			||||||
		res.status(500).json({ error: "Failed to fetch host groups" });
 | 
							res.status(500).json({ error: "Failed to fetch host groups" });
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -10,7 +10,6 @@ const {
 | 
				
			|||||||
	requireManageHosts,
 | 
						requireManageHosts,
 | 
				
			||||||
	requireManageSettings,
 | 
						requireManageSettings,
 | 
				
			||||||
} = require("../middleware/permissions");
 | 
					} = require("../middleware/permissions");
 | 
				
			||||||
const { queueManager, QUEUE_NAMES } = require("../services/automation");
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const router = express.Router();
 | 
					const router = express.Router();
 | 
				
			||||||
const prisma = getPrismaClient();
 | 
					const prisma = getPrismaClient();
 | 
				
			||||||
@@ -1388,66 +1387,6 @@ router.delete(
 | 
				
			|||||||
	},
 | 
						},
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Force immediate report from agent
 | 
					 | 
				
			||||||
router.post(
 | 
					 | 
				
			||||||
	"/:hostId/fetch-report",
 | 
					 | 
				
			||||||
	authenticateToken,
 | 
					 | 
				
			||||||
	requireManageHosts,
 | 
					 | 
				
			||||||
	async (req, res) => {
 | 
					 | 
				
			||||||
		try {
 | 
					 | 
				
			||||||
			const { hostId } = req.params;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			// Get host to verify it exists
 | 
					 | 
				
			||||||
			const host = await prisma.hosts.findUnique({
 | 
					 | 
				
			||||||
				where: { id: hostId },
 | 
					 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			if (!host) {
 | 
					 | 
				
			||||||
				return res.status(404).json({ error: "Host not found" });
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			// Get the agent-commands queue
 | 
					 | 
				
			||||||
			const queue = queueManager.queues[QUEUE_NAMES.AGENT_COMMANDS];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			if (!queue) {
 | 
					 | 
				
			||||||
				return res.status(500).json({
 | 
					 | 
				
			||||||
					error: "Queue not available",
 | 
					 | 
				
			||||||
				});
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			// Add job to queue
 | 
					 | 
				
			||||||
			const job = await queue.add(
 | 
					 | 
				
			||||||
				"report_now",
 | 
					 | 
				
			||||||
				{
 | 
					 | 
				
			||||||
					api_id: host.api_id,
 | 
					 | 
				
			||||||
					type: "report_now",
 | 
					 | 
				
			||||||
				},
 | 
					 | 
				
			||||||
				{
 | 
					 | 
				
			||||||
					attempts: 3,
 | 
					 | 
				
			||||||
					backoff: {
 | 
					 | 
				
			||||||
						type: "exponential",
 | 
					 | 
				
			||||||
						delay: 2000,
 | 
					 | 
				
			||||||
					},
 | 
					 | 
				
			||||||
				},
 | 
					 | 
				
			||||||
			);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			res.json({
 | 
					 | 
				
			||||||
				success: true,
 | 
					 | 
				
			||||||
				message: "Report fetch queued successfully",
 | 
					 | 
				
			||||||
				jobId: job.id,
 | 
					 | 
				
			||||||
				host: {
 | 
					 | 
				
			||||||
					id: host.id,
 | 
					 | 
				
			||||||
					friendlyName: host.friendly_name,
 | 
					 | 
				
			||||||
					apiId: host.api_id,
 | 
					 | 
				
			||||||
				},
 | 
					 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
		} catch (error) {
 | 
					 | 
				
			||||||
			console.error("Force fetch report error:", error);
 | 
					 | 
				
			||||||
			res.status(500).json({ error: "Failed to fetch report" });
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	},
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Toggle agent auto-update setting
 | 
					// Toggle agent auto-update setting
 | 
				
			||||||
router.patch(
 | 
					router.patch(
 | 
				
			||||||
	"/:hostId/auto-update",
 | 
						"/:hostId/auto-update",
 | 
				
			||||||
@@ -1491,66 +1430,6 @@ router.patch(
 | 
				
			|||||||
	},
 | 
						},
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Force agent update for specific host
 | 
					 | 
				
			||||||
router.post(
 | 
					 | 
				
			||||||
	"/:hostId/force-agent-update",
 | 
					 | 
				
			||||||
	authenticateToken,
 | 
					 | 
				
			||||||
	requireManageHosts,
 | 
					 | 
				
			||||||
	async (req, res) => {
 | 
					 | 
				
			||||||
		try {
 | 
					 | 
				
			||||||
			const { hostId } = req.params;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			// Get host to verify it exists
 | 
					 | 
				
			||||||
			const host = await prisma.hosts.findUnique({
 | 
					 | 
				
			||||||
				where: { id: hostId },
 | 
					 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			if (!host) {
 | 
					 | 
				
			||||||
				return res.status(404).json({ error: "Host not found" });
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			// Get the agent-commands queue
 | 
					 | 
				
			||||||
			const queue = queueManager.queues[QUEUE_NAMES.AGENT_COMMANDS];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			if (!queue) {
 | 
					 | 
				
			||||||
				return res.status(500).json({
 | 
					 | 
				
			||||||
					error: "Queue not available",
 | 
					 | 
				
			||||||
				});
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			// Add job to queue
 | 
					 | 
				
			||||||
			const job = await queue.add(
 | 
					 | 
				
			||||||
				"update_agent",
 | 
					 | 
				
			||||||
				{
 | 
					 | 
				
			||||||
					api_id: host.api_id,
 | 
					 | 
				
			||||||
					type: "update_agent",
 | 
					 | 
				
			||||||
				},
 | 
					 | 
				
			||||||
				{
 | 
					 | 
				
			||||||
					attempts: 3,
 | 
					 | 
				
			||||||
					backoff: {
 | 
					 | 
				
			||||||
						type: "exponential",
 | 
					 | 
				
			||||||
						delay: 2000,
 | 
					 | 
				
			||||||
					},
 | 
					 | 
				
			||||||
				},
 | 
					 | 
				
			||||||
			);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			res.json({
 | 
					 | 
				
			||||||
				success: true,
 | 
					 | 
				
			||||||
				message: "Agent update queued successfully",
 | 
					 | 
				
			||||||
				jobId: job.id,
 | 
					 | 
				
			||||||
				host: {
 | 
					 | 
				
			||||||
					id: host.id,
 | 
					 | 
				
			||||||
					friendlyName: host.friendly_name,
 | 
					 | 
				
			||||||
					apiId: host.api_id,
 | 
					 | 
				
			||||||
				},
 | 
					 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
		} catch (error) {
 | 
					 | 
				
			||||||
			console.error("Force agent update error:", error);
 | 
					 | 
				
			||||||
			res.status(500).json({ error: "Failed to force agent update" });
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	},
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Serve the installation script (requires API authentication)
 | 
					// Serve the installation script (requires API authentication)
 | 
				
			||||||
router.get("/install", async (req, res) => {
 | 
					router.get("/install", async (req, res) => {
 | 
				
			||||||
	try {
 | 
						try {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,8 +13,6 @@ router.post("/docker", async (req, res) => {
 | 
				
			|||||||
		const {
 | 
							const {
 | 
				
			||||||
			containers,
 | 
								containers,
 | 
				
			||||||
			images,
 | 
								images,
 | 
				
			||||||
			volumes,
 | 
					 | 
				
			||||||
			networks,
 | 
					 | 
				
			||||||
			updates,
 | 
								updates,
 | 
				
			||||||
			daemon_info: _daemon_info,
 | 
								daemon_info: _daemon_info,
 | 
				
			||||||
			hostname,
 | 
								hostname,
 | 
				
			||||||
@@ -51,8 +49,6 @@ router.post("/docker", async (req, res) => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		let containersProcessed = 0;
 | 
							let containersProcessed = 0;
 | 
				
			||||||
		let imagesProcessed = 0;
 | 
							let imagesProcessed = 0;
 | 
				
			||||||
		let volumesProcessed = 0;
 | 
					 | 
				
			||||||
		let networksProcessed = 0;
 | 
					 | 
				
			||||||
		let updatesProcessed = 0;
 | 
							let updatesProcessed = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Process containers
 | 
							// Process containers
 | 
				
			||||||
@@ -173,114 +169,6 @@ router.post("/docker", async (req, res) => {
 | 
				
			|||||||
			}
 | 
								}
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Process volumes
 | 
					 | 
				
			||||||
		if (volumes && Array.isArray(volumes)) {
 | 
					 | 
				
			||||||
			console.log(`[Docker Integration] Processing ${volumes.length} volumes`);
 | 
					 | 
				
			||||||
			for (const volumeData of volumes) {
 | 
					 | 
				
			||||||
				await prisma.docker_volumes.upsert({
 | 
					 | 
				
			||||||
					where: {
 | 
					 | 
				
			||||||
						host_id_volume_id: {
 | 
					 | 
				
			||||||
							host_id: host.id,
 | 
					 | 
				
			||||||
							volume_id: volumeData.volume_id,
 | 
					 | 
				
			||||||
						},
 | 
					 | 
				
			||||||
					},
 | 
					 | 
				
			||||||
					update: {
 | 
					 | 
				
			||||||
						name: volumeData.name,
 | 
					 | 
				
			||||||
						driver: volumeData.driver || "local",
 | 
					 | 
				
			||||||
						mountpoint: volumeData.mountpoint || null,
 | 
					 | 
				
			||||||
						renderer: volumeData.renderer || null,
 | 
					 | 
				
			||||||
						scope: volumeData.scope || "local",
 | 
					 | 
				
			||||||
						labels: volumeData.labels || null,
 | 
					 | 
				
			||||||
						options: volumeData.options || null,
 | 
					 | 
				
			||||||
						size_bytes: volumeData.size_bytes
 | 
					 | 
				
			||||||
							? BigInt(volumeData.size_bytes)
 | 
					 | 
				
			||||||
							: null,
 | 
					 | 
				
			||||||
						ref_count: volumeData.ref_count || 0,
 | 
					 | 
				
			||||||
						updated_at: now,
 | 
					 | 
				
			||||||
						last_checked: now,
 | 
					 | 
				
			||||||
					},
 | 
					 | 
				
			||||||
					create: {
 | 
					 | 
				
			||||||
						id: uuidv4(),
 | 
					 | 
				
			||||||
						host_id: host.id,
 | 
					 | 
				
			||||||
						volume_id: volumeData.volume_id,
 | 
					 | 
				
			||||||
						name: volumeData.name,
 | 
					 | 
				
			||||||
						driver: volumeData.driver || "local",
 | 
					 | 
				
			||||||
						mountpoint: volumeData.mountpoint || null,
 | 
					 | 
				
			||||||
						renderer: volumeData.renderer || null,
 | 
					 | 
				
			||||||
						scope: volumeData.scope || "local",
 | 
					 | 
				
			||||||
						labels: volumeData.labels || null,
 | 
					 | 
				
			||||||
						options: volumeData.options || null,
 | 
					 | 
				
			||||||
						size_bytes: volumeData.size_bytes
 | 
					 | 
				
			||||||
							? BigInt(volumeData.size_bytes)
 | 
					 | 
				
			||||||
							: null,
 | 
					 | 
				
			||||||
						ref_count: volumeData.ref_count || 0,
 | 
					 | 
				
			||||||
						created_at: parseDate(volumeData.created_at),
 | 
					 | 
				
			||||||
						updated_at: now,
 | 
					 | 
				
			||||||
					},
 | 
					 | 
				
			||||||
				});
 | 
					 | 
				
			||||||
				volumesProcessed++;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		// Process networks
 | 
					 | 
				
			||||||
		if (networks && Array.isArray(networks)) {
 | 
					 | 
				
			||||||
			console.log(
 | 
					 | 
				
			||||||
				`[Docker Integration] Processing ${networks.length} networks`,
 | 
					 | 
				
			||||||
			);
 | 
					 | 
				
			||||||
			for (const networkData of networks) {
 | 
					 | 
				
			||||||
				await prisma.docker_networks.upsert({
 | 
					 | 
				
			||||||
					where: {
 | 
					 | 
				
			||||||
						host_id_network_id: {
 | 
					 | 
				
			||||||
							host_id: host.id,
 | 
					 | 
				
			||||||
							network_id: networkData.network_id,
 | 
					 | 
				
			||||||
						},
 | 
					 | 
				
			||||||
					},
 | 
					 | 
				
			||||||
					update: {
 | 
					 | 
				
			||||||
						name: networkData.name,
 | 
					 | 
				
			||||||
						driver: networkData.driver,
 | 
					 | 
				
			||||||
						scope: networkData.scope || "local",
 | 
					 | 
				
			||||||
						ipv6_enabled: networkData.ipv6_enabled || false,
 | 
					 | 
				
			||||||
						internal: networkData.internal || false,
 | 
					 | 
				
			||||||
						attachable:
 | 
					 | 
				
			||||||
							networkData.attachable !== undefined
 | 
					 | 
				
			||||||
								? networkData.attachable
 | 
					 | 
				
			||||||
								: true,
 | 
					 | 
				
			||||||
						ingress: networkData.ingress || false,
 | 
					 | 
				
			||||||
						config_only: networkData.config_only || false,
 | 
					 | 
				
			||||||
						labels: networkData.labels || null,
 | 
					 | 
				
			||||||
						ipam: networkData.ipam || null,
 | 
					 | 
				
			||||||
						container_count: networkData.container_count || 0,
 | 
					 | 
				
			||||||
						updated_at: now,
 | 
					 | 
				
			||||||
						last_checked: now,
 | 
					 | 
				
			||||||
					},
 | 
					 | 
				
			||||||
					create: {
 | 
					 | 
				
			||||||
						id: uuidv4(),
 | 
					 | 
				
			||||||
						host_id: host.id,
 | 
					 | 
				
			||||||
						network_id: networkData.network_id,
 | 
					 | 
				
			||||||
						name: networkData.name,
 | 
					 | 
				
			||||||
						driver: networkData.driver,
 | 
					 | 
				
			||||||
						scope: networkData.scope || "local",
 | 
					 | 
				
			||||||
						ipv6_enabled: networkData.ipv6_enabled || false,
 | 
					 | 
				
			||||||
						internal: networkData.internal || false,
 | 
					 | 
				
			||||||
						attachable:
 | 
					 | 
				
			||||||
							networkData.attachable !== undefined
 | 
					 | 
				
			||||||
								? networkData.attachable
 | 
					 | 
				
			||||||
								: true,
 | 
					 | 
				
			||||||
						ingress: networkData.ingress || false,
 | 
					 | 
				
			||||||
						config_only: networkData.config_only || false,
 | 
					 | 
				
			||||||
						labels: networkData.labels || null,
 | 
					 | 
				
			||||||
						ipam: networkData.ipam || null,
 | 
					 | 
				
			||||||
						container_count: networkData.container_count || 0,
 | 
					 | 
				
			||||||
						created_at: networkData.created_at
 | 
					 | 
				
			||||||
							? parseDate(networkData.created_at)
 | 
					 | 
				
			||||||
							: null,
 | 
					 | 
				
			||||||
						updated_at: now,
 | 
					 | 
				
			||||||
					},
 | 
					 | 
				
			||||||
				});
 | 
					 | 
				
			||||||
				networksProcessed++;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		// Process updates
 | 
							// Process updates
 | 
				
			||||||
		if (updates && Array.isArray(updates)) {
 | 
							if (updates && Array.isArray(updates)) {
 | 
				
			||||||
			console.log(`[Docker Integration] Processing ${updates.length} updates`);
 | 
								console.log(`[Docker Integration] Processing ${updates.length} updates`);
 | 
				
			||||||
@@ -331,15 +219,13 @@ router.post("/docker", async (req, res) => {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		console.log(
 | 
							console.log(
 | 
				
			||||||
			`[Docker Integration] Successfully processed: ${containersProcessed} containers, ${imagesProcessed} images, ${volumesProcessed} volumes, ${networksProcessed} networks, ${updatesProcessed} updates`,
 | 
								`[Docker Integration] Successfully processed: ${containersProcessed} containers, ${imagesProcessed} images, ${updatesProcessed} updates`,
 | 
				
			||||||
		);
 | 
							);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		res.json({
 | 
							res.json({
 | 
				
			||||||
			message: "Docker data collected successfully",
 | 
								message: "Docker data collected successfully",
 | 
				
			||||||
			containers_received: containersProcessed,
 | 
								containers_received: containersProcessed,
 | 
				
			||||||
			images_received: imagesProcessed,
 | 
								images_received: imagesProcessed,
 | 
				
			||||||
			volumes_received: volumesProcessed,
 | 
					 | 
				
			||||||
			networks_received: networksProcessed,
 | 
					 | 
				
			||||||
			updates_found: updatesProcessed,
 | 
								updates_found: updatesProcessed,
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
	} catch (error) {
 | 
						} catch (error) {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -261,10 +261,8 @@ router.post(
 | 
				
			|||||||
		body("username").notEmpty().withMessage("Username is required"),
 | 
							body("username").notEmpty().withMessage("Username is required"),
 | 
				
			||||||
		body("token")
 | 
							body("token")
 | 
				
			||||||
			.isLength({ min: 6, max: 6 })
 | 
								.isLength({ min: 6, max: 6 })
 | 
				
			||||||
			.withMessage("Token must be 6 characters"),
 | 
								.withMessage("Token must be 6 digits"),
 | 
				
			||||||
		body("token")
 | 
							body("token").isNumeric().withMessage("Token must contain only numbers"),
 | 
				
			||||||
			.matches(/^[A-Z0-9]{6}$/)
 | 
					 | 
				
			||||||
			.withMessage("Token must be 6 alphanumeric characters"),
 | 
					 | 
				
			||||||
	],
 | 
						],
 | 
				
			||||||
	async (req, res) => {
 | 
						async (req, res) => {
 | 
				
			||||||
		try {
 | 
							try {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,105 +0,0 @@
 | 
				
			|||||||
const express = require("express");
 | 
					 | 
				
			||||||
const { getPrismaClient } = require("../config/prisma");
 | 
					 | 
				
			||||||
const { authenticateToken } = require("../middleware/auth");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const router = express.Router();
 | 
					 | 
				
			||||||
const prisma = getPrismaClient();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * GET /api/v1/user/preferences
 | 
					 | 
				
			||||||
 * Get current user's preferences (theme and color theme)
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
router.get("/", authenticateToken, async (req, res) => {
 | 
					 | 
				
			||||||
	try {
 | 
					 | 
				
			||||||
		const userId = req.user.id;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		const user = await prisma.users.findUnique({
 | 
					 | 
				
			||||||
			where: { id: userId },
 | 
					 | 
				
			||||||
			select: {
 | 
					 | 
				
			||||||
				theme_preference: true,
 | 
					 | 
				
			||||||
				color_theme: true,
 | 
					 | 
				
			||||||
			},
 | 
					 | 
				
			||||||
		});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		if (!user) {
 | 
					 | 
				
			||||||
			return res.status(404).json({ error: "User not found" });
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		res.json({
 | 
					 | 
				
			||||||
			theme_preference: user.theme_preference || "dark",
 | 
					 | 
				
			||||||
			color_theme: user.color_theme || "cyber_blue",
 | 
					 | 
				
			||||||
		});
 | 
					 | 
				
			||||||
	} catch (error) {
 | 
					 | 
				
			||||||
		console.error("Error fetching user preferences:", error);
 | 
					 | 
				
			||||||
		res.status(500).json({ error: "Failed to fetch user preferences" });
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * PATCH /api/v1/user/preferences
 | 
					 | 
				
			||||||
 * Update current user's preferences
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
router.patch("/", authenticateToken, async (req, res) => {
 | 
					 | 
				
			||||||
	try {
 | 
					 | 
				
			||||||
		const userId = req.user.id;
 | 
					 | 
				
			||||||
		const { theme_preference, color_theme } = req.body;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		// Validate inputs
 | 
					 | 
				
			||||||
		const updateData = {};
 | 
					 | 
				
			||||||
		if (theme_preference !== undefined) {
 | 
					 | 
				
			||||||
			if (!["light", "dark"].includes(theme_preference)) {
 | 
					 | 
				
			||||||
				return res.status(400).json({
 | 
					 | 
				
			||||||
					error: "Invalid theme preference. Must be 'light' or 'dark'",
 | 
					 | 
				
			||||||
				});
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			updateData.theme_preference = theme_preference;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		if (color_theme !== undefined) {
 | 
					 | 
				
			||||||
			const validColorThemes = [
 | 
					 | 
				
			||||||
				"default",
 | 
					 | 
				
			||||||
				"cyber_blue",
 | 
					 | 
				
			||||||
				"neon_purple",
 | 
					 | 
				
			||||||
				"matrix_green",
 | 
					 | 
				
			||||||
				"ocean_blue",
 | 
					 | 
				
			||||||
				"sunset_gradient",
 | 
					 | 
				
			||||||
			];
 | 
					 | 
				
			||||||
			if (!validColorThemes.includes(color_theme)) {
 | 
					 | 
				
			||||||
				return res.status(400).json({
 | 
					 | 
				
			||||||
					error: `Invalid color theme. Must be one of: ${validColorThemes.join(", ")}`,
 | 
					 | 
				
			||||||
				});
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			updateData.color_theme = color_theme;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		if (Object.keys(updateData).length === 0) {
 | 
					 | 
				
			||||||
			return res
 | 
					 | 
				
			||||||
				.status(400)
 | 
					 | 
				
			||||||
				.json({ error: "No preferences provided to update" });
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		updateData.updated_at = new Date();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		const updatedUser = await prisma.users.update({
 | 
					 | 
				
			||||||
			where: { id: userId },
 | 
					 | 
				
			||||||
			data: updateData,
 | 
					 | 
				
			||||||
			select: {
 | 
					 | 
				
			||||||
				theme_preference: true,
 | 
					 | 
				
			||||||
				color_theme: true,
 | 
					 | 
				
			||||||
			},
 | 
					 | 
				
			||||||
		});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		res.json({
 | 
					 | 
				
			||||||
			message: "Preferences updated successfully",
 | 
					 | 
				
			||||||
			preferences: {
 | 
					 | 
				
			||||||
				theme_preference: updatedUser.theme_preference,
 | 
					 | 
				
			||||||
				color_theme: updatedUser.color_theme,
 | 
					 | 
				
			||||||
			},
 | 
					 | 
				
			||||||
		});
 | 
					 | 
				
			||||||
	} catch (error) {
 | 
					 | 
				
			||||||
		console.error("Error updating user preferences:", error);
 | 
					 | 
				
			||||||
		res.status(500).json({ error: "Failed to update user preferences" });
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
module.exports = router;
 | 
					 | 
				
			||||||
@@ -70,7 +70,6 @@ const integrationRoutes = require("./routes/integrationRoutes");
 | 
				
			|||||||
const wsRoutes = require("./routes/wsRoutes");
 | 
					const wsRoutes = require("./routes/wsRoutes");
 | 
				
			||||||
const agentVersionRoutes = require("./routes/agentVersionRoutes");
 | 
					const agentVersionRoutes = require("./routes/agentVersionRoutes");
 | 
				
			||||||
const metricsRoutes = require("./routes/metricsRoutes");
 | 
					const metricsRoutes = require("./routes/metricsRoutes");
 | 
				
			||||||
const userPreferencesRoutes = require("./routes/userPreferencesRoutes");
 | 
					 | 
				
			||||||
const { initSettings } = require("./services/settingsService");
 | 
					const { initSettings } = require("./services/settingsService");
 | 
				
			||||||
const { queueManager } = require("./services/automation");
 | 
					const { queueManager } = require("./services/automation");
 | 
				
			||||||
const { authenticateToken, requireAdmin } = require("./middleware/auth");
 | 
					const { authenticateToken, requireAdmin } = require("./middleware/auth");
 | 
				
			||||||
@@ -387,7 +386,6 @@ app.use(
 | 
				
			|||||||
			"Authorization",
 | 
								"Authorization",
 | 
				
			||||||
			"Cookie",
 | 
								"Cookie",
 | 
				
			||||||
			"X-Requested-With",
 | 
								"X-Requested-With",
 | 
				
			||||||
			"X-Device-ID", // Allow device ID header for TFA remember-me functionality
 | 
					 | 
				
			||||||
		],
 | 
							],
 | 
				
			||||||
	}),
 | 
						}),
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
@@ -479,7 +477,6 @@ app.use(`/api/${apiVersion}/integrations`, integrationRoutes);
 | 
				
			|||||||
app.use(`/api/${apiVersion}/ws`, wsRoutes);
 | 
					app.use(`/api/${apiVersion}/ws`, wsRoutes);
 | 
				
			||||||
app.use(`/api/${apiVersion}/agent`, agentVersionRoutes);
 | 
					app.use(`/api/${apiVersion}/agent`, agentVersionRoutes);
 | 
				
			||||||
app.use(`/api/${apiVersion}/metrics`, metricsRoutes);
 | 
					app.use(`/api/${apiVersion}/metrics`, metricsRoutes);
 | 
				
			||||||
app.use(`/api/${apiVersion}/user/preferences`, userPreferencesRoutes);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Bull Board - will be populated after queue manager initializes
 | 
					// Bull Board - will be populated after queue manager initializes
 | 
				
			||||||
let bullBoardRouter = null;
 | 
					let bullBoardRouter = null;
 | 
				
			||||||
@@ -559,6 +556,299 @@ app.use(`/bullboard`, (req, res, next) => {
 | 
				
			|||||||
	return res.status(503).json({ error: "Bull Board not initialized yet" });
 | 
						return res.status(503).json({ error: "Bull Board not initialized yet" });
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/*
 | 
				
			||||||
 | 
					// OLD MIDDLEWARE - REMOVED FOR SIMPLIFICATION - DO NOT USE
 | 
				
			||||||
 | 
					if (false) {
 | 
				
			||||||
 | 
							const sessionId = req.cookies["bull-board-session"];
 | 
				
			||||||
 | 
							console.log("Bull Board API call - Session ID:", sessionId ? "present" : "missing");
 | 
				
			||||||
 | 
							console.log("Bull Board API call - Cookies:", req.cookies);
 | 
				
			||||||
 | 
							console.log("Bull Board API call - Bull Board token cookie:", req.cookies["bull-board-token"] ? "present" : "missing");
 | 
				
			||||||
 | 
							console.log("Bull Board API call - Query token:", req.query.token ? "present" : "missing");
 | 
				
			||||||
 | 
							console.log("Bull Board API call - Auth header:", req.headers.authorization ? "present" : "missing");
 | 
				
			||||||
 | 
							console.log("Bull Board API call - Origin:", req.headers.origin || "missing");
 | 
				
			||||||
 | 
							console.log("Bull Board API call - Referer:", req.headers.referer || "missing");
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							// Check if we have any authentication method available
 | 
				
			||||||
 | 
							const hasSession = !!sessionId;
 | 
				
			||||||
 | 
							const hasTokenCookie = !!req.cookies["bull-board-token"];
 | 
				
			||||||
 | 
							const hasQueryToken = !!req.query.token;
 | 
				
			||||||
 | 
							const hasAuthHeader = !!req.headers.authorization;
 | 
				
			||||||
 | 
							const hasReferer = !!req.headers.referer;
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							console.log("Bull Board API call - Auth methods available:", {
 | 
				
			||||||
 | 
								session: hasSession,
 | 
				
			||||||
 | 
								tokenCookie: hasTokenCookie,
 | 
				
			||||||
 | 
								queryToken: hasQueryToken,
 | 
				
			||||||
 | 
								authHeader: hasAuthHeader,
 | 
				
			||||||
 | 
								referer: hasReferer
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							// Check for valid session first
 | 
				
			||||||
 | 
							if (sessionId) {
 | 
				
			||||||
 | 
								const session = bullBoardSessions.get(sessionId);
 | 
				
			||||||
 | 
								console.log("Bull Board API call - Session found:", !!session);
 | 
				
			||||||
 | 
								if (session && Date.now() - session.timestamp < 3600000) {
 | 
				
			||||||
 | 
									// Valid session, extend it
 | 
				
			||||||
 | 
									session.timestamp = Date.now();
 | 
				
			||||||
 | 
									console.log("Bull Board API call - Using existing session, proceeding");
 | 
				
			||||||
 | 
									return next();
 | 
				
			||||||
 | 
								} else if (session) {
 | 
				
			||||||
 | 
									// Expired session, remove it
 | 
				
			||||||
 | 
									console.log("Bull Board API call - Session expired, removing");
 | 
				
			||||||
 | 
									bullBoardSessions.delete(sessionId);
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							// No valid session, check for token as fallback
 | 
				
			||||||
 | 
							let token = req.query.token;
 | 
				
			||||||
 | 
							if (!token && req.headers.authorization) {
 | 
				
			||||||
 | 
								token = req.headers.authorization.replace("Bearer ", "");
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if (!token && req.cookies["bull-board-token"]) {
 | 
				
			||||||
 | 
								token = req.cookies["bull-board-token"];
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							// For API calls, also check if the token is in the referer URL
 | 
				
			||||||
 | 
							// This handles cases where the main page hasn't set the cookie yet
 | 
				
			||||||
 | 
							if (!token && req.headers.referer) {
 | 
				
			||||||
 | 
								try {
 | 
				
			||||||
 | 
									const refererUrl = new URL(req.headers.referer);
 | 
				
			||||||
 | 
									const refererToken = refererUrl.searchParams.get('token');
 | 
				
			||||||
 | 
									if (refererToken) {
 | 
				
			||||||
 | 
										token = refererToken;
 | 
				
			||||||
 | 
										console.log("Bull Board API call - Token found in referer URL:", refererToken.substring(0, 20) + "...");
 | 
				
			||||||
 | 
									} else {
 | 
				
			||||||
 | 
										console.log("Bull Board API call - No token found in referer URL");
 | 
				
			||||||
 | 
										// If no token in referer and no session, return 401 with redirect info
 | 
				
			||||||
 | 
										if (!sessionId) {
 | 
				
			||||||
 | 
											console.log("Bull Board API call - No authentication available, returning 401");
 | 
				
			||||||
 | 
											return res.status(401).json({ 
 | 
				
			||||||
 | 
												error: "Authentication required", 
 | 
				
			||||||
 | 
												message: "Please refresh the page to re-authenticate"
 | 
				
			||||||
 | 
											});
 | 
				
			||||||
 | 
										}
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								} catch (error) {
 | 
				
			||||||
 | 
									console.log("Bull Board API call - Error parsing referer URL:", error.message);
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							if (token) {
 | 
				
			||||||
 | 
								console.log("Bull Board API call - Token found, authenticating");
 | 
				
			||||||
 | 
								// Add token to headers for authentication
 | 
				
			||||||
 | 
								req.headers.authorization = `Bearer ${token}`;
 | 
				
			||||||
 | 
								
 | 
				
			||||||
 | 
								// Authenticate the user
 | 
				
			||||||
 | 
								return authenticateToken(req, res, (err) => {
 | 
				
			||||||
 | 
									if (err) {
 | 
				
			||||||
 | 
										console.log("Bull Board API call - Token authentication failed");
 | 
				
			||||||
 | 
										return res.status(401).json({ error: "Authentication failed" });
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
									return requireAdmin(req, res, (adminErr) => {
 | 
				
			||||||
 | 
										if (adminErr) {
 | 
				
			||||||
 | 
											console.log("Bull Board API call - Admin access required");
 | 
				
			||||||
 | 
											return res.status(403).json({ error: "Admin access required" });
 | 
				
			||||||
 | 
										}
 | 
				
			||||||
 | 
										console.log("Bull Board API call - Token authentication successful");
 | 
				
			||||||
 | 
										return next();
 | 
				
			||||||
 | 
									});
 | 
				
			||||||
 | 
								});
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							// No valid session or token for API calls, deny access
 | 
				
			||||||
 | 
							console.log("Bull Board API call - No valid session or token, denying access");
 | 
				
			||||||
 | 
							return res.status(401).json({ error: "Valid Bull Board session or token required" });
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Check for bull-board-session cookie first
 | 
				
			||||||
 | 
						const sessionId = req.cookies["bull-board-session"];
 | 
				
			||||||
 | 
						if (sessionId) {
 | 
				
			||||||
 | 
							const session = bullBoardSessions.get(sessionId);
 | 
				
			||||||
 | 
							if (session && Date.now() - session.timestamp < 3600000) {
 | 
				
			||||||
 | 
								// 1 hour
 | 
				
			||||||
 | 
								// Valid session, extend it
 | 
				
			||||||
 | 
								session.timestamp = Date.now();
 | 
				
			||||||
 | 
								return next();
 | 
				
			||||||
 | 
							} else if (session) {
 | 
				
			||||||
 | 
								// Expired session, remove it
 | 
				
			||||||
 | 
								bullBoardSessions.delete(sessionId);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// No valid session, check for token
 | 
				
			||||||
 | 
						let token = req.query.token;
 | 
				
			||||||
 | 
						if (!token && req.headers.authorization) {
 | 
				
			||||||
 | 
							token = req.headers.authorization.replace("Bearer ", "");
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if (!token && req.cookies["bull-board-token"]) {
 | 
				
			||||||
 | 
							token = req.cookies["bull-board-token"];
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// If no token, deny access
 | 
				
			||||||
 | 
						if (!token) {
 | 
				
			||||||
 | 
							return res.status(401).json({ error: "Access token required" });
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Add token to headers for authentication
 | 
				
			||||||
 | 
						req.headers.authorization = `Bearer ${token}`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Authenticate the user
 | 
				
			||||||
 | 
						return authenticateToken(req, res, (err) => {
 | 
				
			||||||
 | 
							if (err) {
 | 
				
			||||||
 | 
								return res.status(401).json({ error: "Authentication failed" });
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							return requireAdmin(req, res, (adminErr) => {
 | 
				
			||||||
 | 
								if (adminErr) {
 | 
				
			||||||
 | 
									return res.status(403).json({ error: "Admin access required" });
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								// Authentication successful - create a session
 | 
				
			||||||
 | 
								const newSessionId = require("node:crypto")
 | 
				
			||||||
 | 
									.randomBytes(32)
 | 
				
			||||||
 | 
									.toString("hex");
 | 
				
			||||||
 | 
								bullBoardSessions.set(newSessionId, {
 | 
				
			||||||
 | 
									timestamp: Date.now(),
 | 
				
			||||||
 | 
									userId: req.user.id,
 | 
				
			||||||
 | 
								});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								// Set session cookie with proper configuration for domain access
 | 
				
			||||||
 | 
								const isHttps = process.env.NODE_ENV === "production" || process.env.SERVER_PROTOCOL === "https";
 | 
				
			||||||
 | 
								const cookieOptions = {
 | 
				
			||||||
 | 
									httpOnly: true,
 | 
				
			||||||
 | 
									secure: isHttps,
 | 
				
			||||||
 | 
									maxAge: 3600000, // 1 hour
 | 
				
			||||||
 | 
									path: "/", // Set path to root so it's available for all Bull Board requests
 | 
				
			||||||
 | 
								};
 | 
				
			||||||
 | 
								
 | 
				
			||||||
 | 
								// Configure sameSite based on protocol and environment
 | 
				
			||||||
 | 
								if (isHttps) {
 | 
				
			||||||
 | 
									cookieOptions.sameSite = "none"; // Required for HTTPS cross-origin
 | 
				
			||||||
 | 
								} else {
 | 
				
			||||||
 | 
									cookieOptions.sameSite = "lax"; // Better for HTTP same-origin
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								
 | 
				
			||||||
 | 
								res.cookie("bull-board-session", newSessionId, cookieOptions);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								// Clean up old sessions periodically
 | 
				
			||||||
 | 
								if (bullBoardSessions.size > 100) {
 | 
				
			||||||
 | 
									const now = Date.now();
 | 
				
			||||||
 | 
									for (const [sid, session] of bullBoardSessions.entries()) {
 | 
				
			||||||
 | 
										if (now - session.timestamp > 3600000) {
 | 
				
			||||||
 | 
											bullBoardSessions.delete(sid);
 | 
				
			||||||
 | 
										}
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								return next();
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Second middleware block - COMMENTED OUT - using simplified version above instead
 | 
				
			||||||
 | 
					/*
 | 
				
			||||||
 | 
					app.use(`/bullboard`, (req, res, next) => {
 | 
				
			||||||
 | 
						if (bullBoardRouter) {
 | 
				
			||||||
 | 
							// If this is the main Bull Board page (not an API call), inject the token and create session
 | 
				
			||||||
 | 
							if (!req.path.includes("/api/") && !req.path.includes("/static/") && req.path === "/bullboard") {
 | 
				
			||||||
 | 
								const token = req.query.token;
 | 
				
			||||||
 | 
								console.log("Bull Board main page - Token:", token ? "present" : "missing");
 | 
				
			||||||
 | 
								console.log("Bull Board main page - Query params:", req.query);
 | 
				
			||||||
 | 
								console.log("Bull Board main page - Origin:", req.headers.origin || "missing");
 | 
				
			||||||
 | 
								console.log("Bull Board main page - Referer:", req.headers.referer || "missing");
 | 
				
			||||||
 | 
								console.log("Bull Board main page - Cookies:", req.cookies);
 | 
				
			||||||
 | 
								
 | 
				
			||||||
 | 
								if (token) {
 | 
				
			||||||
 | 
									// Authenticate the user and create a session immediately on page load
 | 
				
			||||||
 | 
									req.headers.authorization = `Bearer ${token}`;
 | 
				
			||||||
 | 
									
 | 
				
			||||||
 | 
									return authenticateToken(req, res, (err) => {
 | 
				
			||||||
 | 
										if (err) {
 | 
				
			||||||
 | 
											console.log("Bull Board main page - Token authentication failed");
 | 
				
			||||||
 | 
											return res.status(401).json({ error: "Authentication failed" });
 | 
				
			||||||
 | 
										}
 | 
				
			||||||
 | 
										return requireAdmin(req, res, (adminErr) => {
 | 
				
			||||||
 | 
											if (adminErr) {
 | 
				
			||||||
 | 
												console.log("Bull Board main page - Admin access required");
 | 
				
			||||||
 | 
												return res.status(403).json({ error: "Admin access required" });
 | 
				
			||||||
 | 
											}
 | 
				
			||||||
 | 
											
 | 
				
			||||||
 | 
											console.log("Bull Board main page - Token authentication successful, creating session");
 | 
				
			||||||
 | 
											
 | 
				
			||||||
 | 
											// Create a Bull Board session immediately
 | 
				
			||||||
 | 
											const newSessionId = require("node:crypto")
 | 
				
			||||||
 | 
												.randomBytes(32)
 | 
				
			||||||
 | 
												.toString("hex");
 | 
				
			||||||
 | 
											bullBoardSessions.set(newSessionId, {
 | 
				
			||||||
 | 
												timestamp: Date.now(),
 | 
				
			||||||
 | 
												userId: req.user.id,
 | 
				
			||||||
 | 
											});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
											// Set session cookie with proper configuration for domain access
 | 
				
			||||||
 | 
											const sessionCookieOptions = {
 | 
				
			||||||
 | 
												httpOnly: true,
 | 
				
			||||||
 | 
												secure: false, // Always false for HTTP
 | 
				
			||||||
 | 
												maxAge: 3600000, // 1 hour
 | 
				
			||||||
 | 
												path: "/", // Set path to root so it's available for all Bull Board requests
 | 
				
			||||||
 | 
												sameSite: "lax", // Always lax for HTTP
 | 
				
			||||||
 | 
											};
 | 
				
			||||||
 | 
											
 | 
				
			||||||
 | 
											res.cookie("bull-board-session", newSessionId, sessionCookieOptions);
 | 
				
			||||||
 | 
											console.log("Bull Board main page - Session created:", newSessionId);
 | 
				
			||||||
 | 
											console.log("Bull Board main page - Cookie options:", sessionCookieOptions);
 | 
				
			||||||
 | 
											
 | 
				
			||||||
 | 
											// Also set a token cookie for API calls as a fallback
 | 
				
			||||||
 | 
											const tokenCookieOptions = {
 | 
				
			||||||
 | 
												httpOnly: false, // Allow JavaScript to access it
 | 
				
			||||||
 | 
												secure: false, // Always false for HTTP
 | 
				
			||||||
 | 
												maxAge: 3600000, // 1 hour
 | 
				
			||||||
 | 
												path: "/", // Set path to root for broader compatibility
 | 
				
			||||||
 | 
												sameSite: "lax", // Always lax for HTTP
 | 
				
			||||||
 | 
											};
 | 
				
			||||||
 | 
											
 | 
				
			||||||
 | 
											res.cookie("bull-board-token", token, tokenCookieOptions);
 | 
				
			||||||
 | 
											console.log("Bull Board main page - Token cookie also set for API fallback");
 | 
				
			||||||
 | 
											
 | 
				
			||||||
 | 
											// Clean up old sessions periodically
 | 
				
			||||||
 | 
											if (bullBoardSessions.size > 100) {
 | 
				
			||||||
 | 
												const now = Date.now();
 | 
				
			||||||
 | 
												for (const [sid, session] of bullBoardSessions.entries()) {
 | 
				
			||||||
 | 
													if (now - session.timestamp > 3600000) {
 | 
				
			||||||
 | 
														bullBoardSessions.delete(sid);
 | 
				
			||||||
 | 
													}
 | 
				
			||||||
 | 
												}
 | 
				
			||||||
 | 
											}
 | 
				
			||||||
 | 
											
 | 
				
			||||||
 | 
											// Now proceed to serve the Bull Board page
 | 
				
			||||||
 | 
											return bullBoardRouter(req, res, next);
 | 
				
			||||||
 | 
										});
 | 
				
			||||||
 | 
									});
 | 
				
			||||||
 | 
								} else {
 | 
				
			||||||
 | 
									console.log("Bull Board main page - No token provided, checking for existing session");
 | 
				
			||||||
 | 
									// Check if we have an existing session
 | 
				
			||||||
 | 
									const sessionId = req.cookies["bull-board-session"];
 | 
				
			||||||
 | 
									if (sessionId) {
 | 
				
			||||||
 | 
										const session = bullBoardSessions.get(sessionId);
 | 
				
			||||||
 | 
										if (session && Date.now() - session.timestamp < 3600000) {
 | 
				
			||||||
 | 
											console.log("Bull Board main page - Using existing session");
 | 
				
			||||||
 | 
											// Extend session
 | 
				
			||||||
 | 
											session.timestamp = Date.now();
 | 
				
			||||||
 | 
											return bullBoardRouter(req, res, next);
 | 
				
			||||||
 | 
										} else if (session) {
 | 
				
			||||||
 | 
											console.log("Bull Board main page - Session expired, removing");
 | 
				
			||||||
 | 
											bullBoardSessions.delete(sessionId);
 | 
				
			||||||
 | 
										}
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
									console.log("Bull Board main page - No valid session, denying access");
 | 
				
			||||||
 | 
									return res.status(401).json({ error: "Access token required" });
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							return bullBoardRouter(req, res, next);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return res.status(503).json({ error: "Bull Board not initialized yet" });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Error handler specifically for Bull Board routes
 | 
					// Error handler specifically for Bull Board routes
 | 
				
			||||||
app.use("/bullboard", (err, req, res, _next) => {
 | 
					app.use("/bullboard", (err, req, res, _next) => {
 | 
				
			||||||
	console.error("Bull Board error on", req.method, req.url);
 | 
						console.error("Bull Board error on", req.method, req.url);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -176,15 +176,6 @@ function pushSettingsUpdate(apiId, newInterval) {
 | 
				
			|||||||
	);
 | 
						);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function pushUpdateAgent(apiId) {
 | 
					 | 
				
			||||||
	const ws = apiIdToSocket.get(apiId);
 | 
					 | 
				
			||||||
	safeSend(ws, JSON.stringify({ type: "update_agent" }));
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function getConnectionByApiId(apiId) {
 | 
					 | 
				
			||||||
	return apiIdToSocket.get(apiId);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function pushUpdateNotification(apiId, updateInfo) {
 | 
					function pushUpdateNotification(apiId, updateInfo) {
 | 
				
			||||||
	const ws = apiIdToSocket.get(apiId);
 | 
						const ws = apiIdToSocket.get(apiId);
 | 
				
			||||||
	if (ws && ws.readyState === WebSocket.OPEN) {
 | 
						if (ws && ws.readyState === WebSocket.OPEN) {
 | 
				
			||||||
@@ -339,12 +330,10 @@ module.exports = {
 | 
				
			|||||||
	broadcastSettingsUpdate,
 | 
						broadcastSettingsUpdate,
 | 
				
			||||||
	pushReportNow,
 | 
						pushReportNow,
 | 
				
			||||||
	pushSettingsUpdate,
 | 
						pushSettingsUpdate,
 | 
				
			||||||
	pushUpdateAgent,
 | 
					 | 
				
			||||||
	pushUpdateNotification,
 | 
						pushUpdateNotification,
 | 
				
			||||||
	pushUpdateNotificationToAll,
 | 
						pushUpdateNotificationToAll,
 | 
				
			||||||
	// Expose read-only view of connected agents
 | 
						// Expose read-only view of connected agents
 | 
				
			||||||
	getConnectedApiIds: () => Array.from(apiIdToSocket.keys()),
 | 
						getConnectedApiIds: () => Array.from(apiIdToSocket.keys()),
 | 
				
			||||||
	getConnectionByApiId,
 | 
					 | 
				
			||||||
	isConnected: (apiId) => {
 | 
						isConnected: (apiId) => {
 | 
				
			||||||
		const ws = apiIdToSocket.get(apiId);
 | 
							const ws = apiIdToSocket.get(apiId);
 | 
				
			||||||
		return !!ws && ws.readyState === WebSocket.OPEN;
 | 
							return !!ws && ws.readyState === WebSocket.OPEN;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,343 +0,0 @@
 | 
				
			|||||||
const { prisma } = require("./shared/prisma");
 | 
					 | 
				
			||||||
const https = require("node:https");
 | 
					 | 
				
			||||||
const http = require("node:http");
 | 
					 | 
				
			||||||
const { v4: uuidv4 } = require("uuid");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Docker Image Update Check Automation
 | 
					 | 
				
			||||||
 * Checks for Docker image updates by comparing local digests with remote registry digests
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
class DockerImageUpdateCheck {
 | 
					 | 
				
			||||||
	constructor(queueManager) {
 | 
					 | 
				
			||||||
		this.queueManager = queueManager;
 | 
					 | 
				
			||||||
		this.queueName = "docker-image-update-check";
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	/**
 | 
					 | 
				
			||||||
	 * Get remote digest from Docker registry using HEAD request
 | 
					 | 
				
			||||||
	 * Supports Docker Hub, GHCR, and other OCI-compliant registries
 | 
					 | 
				
			||||||
	 */
 | 
					 | 
				
			||||||
	async getRemoteDigest(imageName, tag = "latest") {
 | 
					 | 
				
			||||||
		return new Promise((resolve, reject) => {
 | 
					 | 
				
			||||||
			// Parse image name to determine registry
 | 
					 | 
				
			||||||
			const registryInfo = this.parseImageName(imageName);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			// Construct manifest URL
 | 
					 | 
				
			||||||
			const manifestPath = `/v2/${registryInfo.repository}/manifests/${tag}`;
 | 
					 | 
				
			||||||
			const options = {
 | 
					 | 
				
			||||||
				hostname: registryInfo.registry,
 | 
					 | 
				
			||||||
				path: manifestPath,
 | 
					 | 
				
			||||||
				method: "HEAD",
 | 
					 | 
				
			||||||
				headers: {
 | 
					 | 
				
			||||||
					Accept:
 | 
					 | 
				
			||||||
						"application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json",
 | 
					 | 
				
			||||||
					"User-Agent": "PatchMon/1.0",
 | 
					 | 
				
			||||||
				},
 | 
					 | 
				
			||||||
			};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			// Add authentication token for Docker Hub if needed
 | 
					 | 
				
			||||||
			if (
 | 
					 | 
				
			||||||
				registryInfo.registry === "registry-1.docker.io" &&
 | 
					 | 
				
			||||||
				registryInfo.isPublic
 | 
					 | 
				
			||||||
			) {
 | 
					 | 
				
			||||||
				// For anonymous public images, we may need to get an auth token first
 | 
					 | 
				
			||||||
				// For now, try without auth (works for public images)
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			// Choose HTTP or HTTPS
 | 
					 | 
				
			||||||
			const client = registryInfo.isSecure ? https : http;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			const req = client.request(options, (res) => {
 | 
					 | 
				
			||||||
				if (res.statusCode === 401 || res.statusCode === 403) {
 | 
					 | 
				
			||||||
					// Authentication required - skip for now (would need to implement auth)
 | 
					 | 
				
			||||||
					return reject(
 | 
					 | 
				
			||||||
						new Error(`Authentication required for ${imageName}:${tag}`),
 | 
					 | 
				
			||||||
					);
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				if (res.statusCode !== 200) {
 | 
					 | 
				
			||||||
					return reject(
 | 
					 | 
				
			||||||
						new Error(
 | 
					 | 
				
			||||||
							`Registry returned status ${res.statusCode} for ${imageName}:${tag}`,
 | 
					 | 
				
			||||||
						),
 | 
					 | 
				
			||||||
					);
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				// Get digest from Docker-Content-Digest header
 | 
					 | 
				
			||||||
				const digest = res.headers["docker-content-digest"];
 | 
					 | 
				
			||||||
				if (!digest) {
 | 
					 | 
				
			||||||
					return reject(
 | 
					 | 
				
			||||||
						new Error(
 | 
					 | 
				
			||||||
							`No Docker-Content-Digest header for ${imageName}:${tag}`,
 | 
					 | 
				
			||||||
						),
 | 
					 | 
				
			||||||
					);
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				// Clean up digest (remove sha256: prefix if present)
 | 
					 | 
				
			||||||
				const cleanDigest = digest.startsWith("sha256:")
 | 
					 | 
				
			||||||
					? digest.substring(7)
 | 
					 | 
				
			||||||
					: digest;
 | 
					 | 
				
			||||||
				resolve(cleanDigest);
 | 
					 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			req.on("error", (error) => {
 | 
					 | 
				
			||||||
				reject(error);
 | 
					 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			req.setTimeout(10000, () => {
 | 
					 | 
				
			||||||
				req.destroy();
 | 
					 | 
				
			||||||
				reject(new Error(`Timeout getting digest for ${imageName}:${tag}`));
 | 
					 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			req.end();
 | 
					 | 
				
			||||||
		});
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	/**
 | 
					 | 
				
			||||||
	 * Parse image name to extract registry, repository, and determine if secure
 | 
					 | 
				
			||||||
	 */
 | 
					 | 
				
			||||||
	parseImageName(imageName) {
 | 
					 | 
				
			||||||
		let registry = "registry-1.docker.io";
 | 
					 | 
				
			||||||
		let repository = imageName;
 | 
					 | 
				
			||||||
		const isSecure = true;
 | 
					 | 
				
			||||||
		let isPublic = true;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		// Handle explicit registries (ghcr.io, quay.io, etc.)
 | 
					 | 
				
			||||||
		if (imageName.includes("/")) {
 | 
					 | 
				
			||||||
			const parts = imageName.split("/");
 | 
					 | 
				
			||||||
			const firstPart = parts[0];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			// Check for known registries
 | 
					 | 
				
			||||||
			if (firstPart.includes(".") || firstPart === "localhost") {
 | 
					 | 
				
			||||||
				registry = firstPart;
 | 
					 | 
				
			||||||
				repository = parts.slice(1).join("/");
 | 
					 | 
				
			||||||
				isPublic = false; // Assume private registries need auth for now
 | 
					 | 
				
			||||||
			} else {
 | 
					 | 
				
			||||||
				// Docker Hub - registry-1.docker.io
 | 
					 | 
				
			||||||
				repository = imageName;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		// Docker Hub official images (no namespace)
 | 
					 | 
				
			||||||
		if (!repository.includes("/")) {
 | 
					 | 
				
			||||||
			repository = `library/${repository}`;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		return {
 | 
					 | 
				
			||||||
			registry,
 | 
					 | 
				
			||||||
			repository,
 | 
					 | 
				
			||||||
			isSecure,
 | 
					 | 
				
			||||||
			isPublic,
 | 
					 | 
				
			||||||
		};
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	/**
 | 
					 | 
				
			||||||
	 * Process Docker image update check job
 | 
					 | 
				
			||||||
	 */
 | 
					 | 
				
			||||||
	async process(_job) {
 | 
					 | 
				
			||||||
		const startTime = Date.now();
 | 
					 | 
				
			||||||
		console.log("🐳 Starting Docker image update check...");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		try {
 | 
					 | 
				
			||||||
			// Get all Docker images that have a digest and repository
 | 
					 | 
				
			||||||
			const images = await prisma.docker_images.findMany({
 | 
					 | 
				
			||||||
				where: {
 | 
					 | 
				
			||||||
					digest: {
 | 
					 | 
				
			||||||
						not: null,
 | 
					 | 
				
			||||||
					},
 | 
					 | 
				
			||||||
					repository: {
 | 
					 | 
				
			||||||
						not: null,
 | 
					 | 
				
			||||||
					},
 | 
					 | 
				
			||||||
				},
 | 
					 | 
				
			||||||
				include: {
 | 
					 | 
				
			||||||
					docker_image_updates: true,
 | 
					 | 
				
			||||||
				},
 | 
					 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			console.log(`📦 Found ${images.length} images to check for updates`);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			let checkedCount = 0;
 | 
					 | 
				
			||||||
			let updateCount = 0;
 | 
					 | 
				
			||||||
			let errorCount = 0;
 | 
					 | 
				
			||||||
			const errors = [];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			// Process images in batches to avoid overwhelming the API
 | 
					 | 
				
			||||||
			const batchSize = 10;
 | 
					 | 
				
			||||||
			for (let i = 0; i < images.length; i += batchSize) {
 | 
					 | 
				
			||||||
				const batch = images.slice(i, i + batchSize);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				// Process batch concurrently with Promise.allSettled for error tolerance
 | 
					 | 
				
			||||||
				const _results = await Promise.allSettled(
 | 
					 | 
				
			||||||
					batch.map(async (image) => {
 | 
					 | 
				
			||||||
						try {
 | 
					 | 
				
			||||||
							checkedCount++;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
							// Skip local images (no digest means they're local)
 | 
					 | 
				
			||||||
							if (!image.digest || image.digest.trim() === "") {
 | 
					 | 
				
			||||||
								return { image, skipped: true, reason: "No digest" };
 | 
					 | 
				
			||||||
							}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
							// Get clean digest (remove sha256: prefix if present)
 | 
					 | 
				
			||||||
							const localDigest = image.digest.startsWith("sha256:")
 | 
					 | 
				
			||||||
								? image.digest.substring(7)
 | 
					 | 
				
			||||||
								: image.digest;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
							// Get remote digest from registry
 | 
					 | 
				
			||||||
							const remoteDigest = await this.getRemoteDigest(
 | 
					 | 
				
			||||||
								image.repository,
 | 
					 | 
				
			||||||
								image.tag || "latest",
 | 
					 | 
				
			||||||
							);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
							// Compare digests
 | 
					 | 
				
			||||||
							if (localDigest !== remoteDigest) {
 | 
					 | 
				
			||||||
								console.log(
 | 
					 | 
				
			||||||
									`🔄 Update found: ${image.repository}:${image.tag} (local: ${localDigest.substring(0, 12)}..., remote: ${remoteDigest.substring(0, 12)}...)`,
 | 
					 | 
				
			||||||
								);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
								// Store digest info in changelog_url field as JSON
 | 
					 | 
				
			||||||
								const digestInfo = JSON.stringify({
 | 
					 | 
				
			||||||
									method: "digest_comparison",
 | 
					 | 
				
			||||||
									current_digest: localDigest,
 | 
					 | 
				
			||||||
									available_digest: remoteDigest,
 | 
					 | 
				
			||||||
									checked_at: new Date().toISOString(),
 | 
					 | 
				
			||||||
								});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
								// Upsert the update record
 | 
					 | 
				
			||||||
								await prisma.docker_image_updates.upsert({
 | 
					 | 
				
			||||||
									where: {
 | 
					 | 
				
			||||||
										image_id_available_tag: {
 | 
					 | 
				
			||||||
											image_id: image.id,
 | 
					 | 
				
			||||||
											available_tag: image.tag || "latest",
 | 
					 | 
				
			||||||
										},
 | 
					 | 
				
			||||||
									},
 | 
					 | 
				
			||||||
									update: {
 | 
					 | 
				
			||||||
										updated_at: new Date(),
 | 
					 | 
				
			||||||
										changelog_url: digestInfo,
 | 
					 | 
				
			||||||
										severity: "digest_changed",
 | 
					 | 
				
			||||||
									},
 | 
					 | 
				
			||||||
									create: {
 | 
					 | 
				
			||||||
										id: uuidv4(),
 | 
					 | 
				
			||||||
										image_id: image.id,
 | 
					 | 
				
			||||||
										current_tag: image.tag || "latest",
 | 
					 | 
				
			||||||
										available_tag: image.tag || "latest",
 | 
					 | 
				
			||||||
										severity: "digest_changed",
 | 
					 | 
				
			||||||
										changelog_url: digestInfo,
 | 
					 | 
				
			||||||
										updated_at: new Date(),
 | 
					 | 
				
			||||||
									},
 | 
					 | 
				
			||||||
								});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
								// Update last_checked timestamp on image
 | 
					 | 
				
			||||||
								await prisma.docker_images.update({
 | 
					 | 
				
			||||||
									where: { id: image.id },
 | 
					 | 
				
			||||||
									data: { last_checked: new Date() },
 | 
					 | 
				
			||||||
								});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
								updateCount++;
 | 
					 | 
				
			||||||
								return { image, updated: true };
 | 
					 | 
				
			||||||
							} else {
 | 
					 | 
				
			||||||
								// No update - still update last_checked
 | 
					 | 
				
			||||||
								await prisma.docker_images.update({
 | 
					 | 
				
			||||||
									where: { id: image.id },
 | 
					 | 
				
			||||||
									data: { last_checked: new Date() },
 | 
					 | 
				
			||||||
								});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
								// Remove existing update record if digest matches now
 | 
					 | 
				
			||||||
								const existingUpdate = image.docker_image_updates?.find(
 | 
					 | 
				
			||||||
									(u) => u.available_tag === (image.tag || "latest"),
 | 
					 | 
				
			||||||
								);
 | 
					 | 
				
			||||||
								if (existingUpdate) {
 | 
					 | 
				
			||||||
									await prisma.docker_image_updates.delete({
 | 
					 | 
				
			||||||
										where: { id: existingUpdate.id },
 | 
					 | 
				
			||||||
									});
 | 
					 | 
				
			||||||
								}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
								return { image, updated: false };
 | 
					 | 
				
			||||||
							}
 | 
					 | 
				
			||||||
						} catch (error) {
 | 
					 | 
				
			||||||
							errorCount++;
 | 
					 | 
				
			||||||
							const errorMsg = `Error checking ${image.repository}:${image.tag}: ${error.message}`;
 | 
					 | 
				
			||||||
							errors.push(errorMsg);
 | 
					 | 
				
			||||||
							console.error(`❌ ${errorMsg}`);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
							// Still update last_checked even on error
 | 
					 | 
				
			||||||
							try {
 | 
					 | 
				
			||||||
								await prisma.docker_images.update({
 | 
					 | 
				
			||||||
									where: { id: image.id },
 | 
					 | 
				
			||||||
									data: { last_checked: new Date() },
 | 
					 | 
				
			||||||
								});
 | 
					 | 
				
			||||||
							} catch (_updateError) {
 | 
					 | 
				
			||||||
								// Ignore update errors
 | 
					 | 
				
			||||||
							}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
							return { image, error: error.message };
 | 
					 | 
				
			||||||
						}
 | 
					 | 
				
			||||||
					}),
 | 
					 | 
				
			||||||
				);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				// Log batch progress
 | 
					 | 
				
			||||||
				if (i + batchSize < images.length) {
 | 
					 | 
				
			||||||
					console.log(
 | 
					 | 
				
			||||||
						`⏳ Processed ${Math.min(i + batchSize, images.length)}/${images.length} images...`,
 | 
					 | 
				
			||||||
					);
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				// Small delay between batches to be respectful to registries
 | 
					 | 
				
			||||||
				if (i + batchSize < images.length) {
 | 
					 | 
				
			||||||
					await new Promise((resolve) => setTimeout(resolve, 500));
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			const executionTime = Date.now() - startTime;
 | 
					 | 
				
			||||||
			console.log(
 | 
					 | 
				
			||||||
				`✅ Docker image update check completed in ${executionTime}ms - Checked: ${checkedCount}, Updates: ${updateCount}, Errors: ${errorCount}`,
 | 
					 | 
				
			||||||
			);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			return {
 | 
					 | 
				
			||||||
				success: true,
 | 
					 | 
				
			||||||
				checked: checkedCount,
 | 
					 | 
				
			||||||
				updates: updateCount,
 | 
					 | 
				
			||||||
				errors: errorCount,
 | 
					 | 
				
			||||||
				executionTime,
 | 
					 | 
				
			||||||
				errorDetails: errors,
 | 
					 | 
				
			||||||
			};
 | 
					 | 
				
			||||||
		} catch (error) {
 | 
					 | 
				
			||||||
			const executionTime = Date.now() - startTime;
 | 
					 | 
				
			||||||
			console.error(
 | 
					 | 
				
			||||||
				`❌ Docker image update check failed after ${executionTime}ms:`,
 | 
					 | 
				
			||||||
				error.message,
 | 
					 | 
				
			||||||
			);
 | 
					 | 
				
			||||||
			throw error;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	/**
 | 
					 | 
				
			||||||
	 * Schedule recurring Docker image update check (daily at 2 AM)
 | 
					 | 
				
			||||||
	 */
 | 
					 | 
				
			||||||
	async schedule() {
 | 
					 | 
				
			||||||
		const job = await this.queueManager.queues[this.queueName].add(
 | 
					 | 
				
			||||||
			"docker-image-update-check",
 | 
					 | 
				
			||||||
			{},
 | 
					 | 
				
			||||||
			{
 | 
					 | 
				
			||||||
				repeat: { cron: "0 2 * * *" }, // Daily at 2 AM
 | 
					 | 
				
			||||||
				jobId: "docker-image-update-check-recurring",
 | 
					 | 
				
			||||||
			},
 | 
					 | 
				
			||||||
		);
 | 
					 | 
				
			||||||
		console.log("✅ Docker image update check scheduled");
 | 
					 | 
				
			||||||
		return job;
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	/**
 | 
					 | 
				
			||||||
	 * Trigger manual Docker image update check
 | 
					 | 
				
			||||||
	 */
 | 
					 | 
				
			||||||
	async triggerManual() {
 | 
					 | 
				
			||||||
		const job = await this.queueManager.queues[this.queueName].add(
 | 
					 | 
				
			||||||
			"docker-image-update-check-manual",
 | 
					 | 
				
			||||||
			{},
 | 
					 | 
				
			||||||
			{ priority: 1 },
 | 
					 | 
				
			||||||
		);
 | 
					 | 
				
			||||||
		console.log("✅ Manual Docker image update check triggered");
 | 
					 | 
				
			||||||
		return job;
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
module.exports = DockerImageUpdateCheck;
 | 
					 | 
				
			||||||
@@ -2,7 +2,6 @@ const { Queue, Worker } = require("bullmq");
 | 
				
			|||||||
const { redis, redisConnection } = require("./shared/redis");
 | 
					const { redis, redisConnection } = require("./shared/redis");
 | 
				
			||||||
const { prisma } = require("./shared/prisma");
 | 
					const { prisma } = require("./shared/prisma");
 | 
				
			||||||
const agentWs = require("../agentWs");
 | 
					const agentWs = require("../agentWs");
 | 
				
			||||||
const { v4: uuidv4 } = require("uuid");
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Import automation classes
 | 
					// Import automation classes
 | 
				
			||||||
const GitHubUpdateCheck = require("./githubUpdateCheck");
 | 
					const GitHubUpdateCheck = require("./githubUpdateCheck");
 | 
				
			||||||
@@ -10,7 +9,6 @@ const SessionCleanup = require("./sessionCleanup");
 | 
				
			|||||||
const OrphanedRepoCleanup = require("./orphanedRepoCleanup");
 | 
					const OrphanedRepoCleanup = require("./orphanedRepoCleanup");
 | 
				
			||||||
const OrphanedPackageCleanup = require("./orphanedPackageCleanup");
 | 
					const OrphanedPackageCleanup = require("./orphanedPackageCleanup");
 | 
				
			||||||
const DockerInventoryCleanup = require("./dockerInventoryCleanup");
 | 
					const DockerInventoryCleanup = require("./dockerInventoryCleanup");
 | 
				
			||||||
const DockerImageUpdateCheck = require("./dockerImageUpdateCheck");
 | 
					 | 
				
			||||||
const MetricsReporting = require("./metricsReporting");
 | 
					const MetricsReporting = require("./metricsReporting");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Queue names
 | 
					// Queue names
 | 
				
			||||||
@@ -20,7 +18,6 @@ const QUEUE_NAMES = {
 | 
				
			|||||||
	ORPHANED_REPO_CLEANUP: "orphaned-repo-cleanup",
 | 
						ORPHANED_REPO_CLEANUP: "orphaned-repo-cleanup",
 | 
				
			||||||
	ORPHANED_PACKAGE_CLEANUP: "orphaned-package-cleanup",
 | 
						ORPHANED_PACKAGE_CLEANUP: "orphaned-package-cleanup",
 | 
				
			||||||
	DOCKER_INVENTORY_CLEANUP: "docker-inventory-cleanup",
 | 
						DOCKER_INVENTORY_CLEANUP: "docker-inventory-cleanup",
 | 
				
			||||||
	DOCKER_IMAGE_UPDATE_CHECK: "docker-image-update-check",
 | 
					 | 
				
			||||||
	METRICS_REPORTING: "metrics-reporting",
 | 
						METRICS_REPORTING: "metrics-reporting",
 | 
				
			||||||
	AGENT_COMMANDS: "agent-commands",
 | 
						AGENT_COMMANDS: "agent-commands",
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
@@ -100,8 +97,6 @@ class QueueManager {
 | 
				
			|||||||
			new OrphanedPackageCleanup(this);
 | 
								new OrphanedPackageCleanup(this);
 | 
				
			||||||
		this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP] =
 | 
							this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP] =
 | 
				
			||||||
			new DockerInventoryCleanup(this);
 | 
								new DockerInventoryCleanup(this);
 | 
				
			||||||
		this.automations[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK] =
 | 
					 | 
				
			||||||
			new DockerImageUpdateCheck(this);
 | 
					 | 
				
			||||||
		this.automations[QUEUE_NAMES.METRICS_REPORTING] = new MetricsReporting(
 | 
							this.automations[QUEUE_NAMES.METRICS_REPORTING] = new MetricsReporting(
 | 
				
			||||||
			this,
 | 
								this,
 | 
				
			||||||
		);
 | 
							);
 | 
				
			||||||
@@ -172,15 +167,6 @@ class QueueManager {
 | 
				
			|||||||
			workerOptions,
 | 
								workerOptions,
 | 
				
			||||||
		);
 | 
							);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Docker Image Update Check Worker
 | 
					 | 
				
			||||||
		this.workers[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK] = new Worker(
 | 
					 | 
				
			||||||
			QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK,
 | 
					 | 
				
			||||||
			this.automations[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK].process.bind(
 | 
					 | 
				
			||||||
				this.automations[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK],
 | 
					 | 
				
			||||||
			),
 | 
					 | 
				
			||||||
			workerOptions,
 | 
					 | 
				
			||||||
		);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		// Metrics Reporting Worker
 | 
							// Metrics Reporting Worker
 | 
				
			||||||
		this.workers[QUEUE_NAMES.METRICS_REPORTING] = new Worker(
 | 
							this.workers[QUEUE_NAMES.METRICS_REPORTING] = new Worker(
 | 
				
			||||||
			QUEUE_NAMES.METRICS_REPORTING,
 | 
								QUEUE_NAMES.METRICS_REPORTING,
 | 
				
			||||||
@@ -197,87 +183,15 @@ class QueueManager {
 | 
				
			|||||||
				const { api_id, type } = job.data;
 | 
									const { api_id, type } = job.data;
 | 
				
			||||||
				console.log(`Processing agent command: ${type} for ${api_id}`);
 | 
									console.log(`Processing agent command: ${type} for ${api_id}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
				// Log job to job_history
 | 
									// Send command via WebSocket based on type
 | 
				
			||||||
				let historyRecord = null;
 | 
									if (type === "report_now") {
 | 
				
			||||||
				try {
 | 
										agentWs.pushReportNow(api_id);
 | 
				
			||||||
					const host = await prisma.hosts.findUnique({
 | 
									} else if (type === "settings_update") {
 | 
				
			||||||
						where: { api_id },
 | 
										// For settings update, we need additional data
 | 
				
			||||||
						select: { id: true },
 | 
										const { update_interval } = job.data;
 | 
				
			||||||
					});
 | 
										agentWs.pushSettingsUpdate(api_id, update_interval);
 | 
				
			||||||
 | 
									} else {
 | 
				
			||||||
					if (host) {
 | 
										console.error(`Unknown agent command type: ${type}`);
 | 
				
			||||||
						historyRecord = await prisma.job_history.create({
 | 
					 | 
				
			||||||
							data: {
 | 
					 | 
				
			||||||
								id: uuidv4(),
 | 
					 | 
				
			||||||
								job_id: job.id,
 | 
					 | 
				
			||||||
								queue_name: QUEUE_NAMES.AGENT_COMMANDS,
 | 
					 | 
				
			||||||
								job_name: type,
 | 
					 | 
				
			||||||
								host_id: host.id,
 | 
					 | 
				
			||||||
								api_id: api_id,
 | 
					 | 
				
			||||||
								status: "active",
 | 
					 | 
				
			||||||
								attempt_number: job.attemptsMade + 1,
 | 
					 | 
				
			||||||
								created_at: new Date(),
 | 
					 | 
				
			||||||
								updated_at: new Date(),
 | 
					 | 
				
			||||||
							},
 | 
					 | 
				
			||||||
						});
 | 
					 | 
				
			||||||
						console.log(`📝 Logged job to job_history: ${job.id} (${type})`);
 | 
					 | 
				
			||||||
					}
 | 
					 | 
				
			||||||
				} catch (error) {
 | 
					 | 
				
			||||||
					console.error("Failed to log job to job_history:", error);
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				try {
 | 
					 | 
				
			||||||
					// Send command via WebSocket based on type
 | 
					 | 
				
			||||||
					if (type === "report_now") {
 | 
					 | 
				
			||||||
						agentWs.pushReportNow(api_id);
 | 
					 | 
				
			||||||
					} else if (type === "settings_update") {
 | 
					 | 
				
			||||||
						// For settings update, we need additional data
 | 
					 | 
				
			||||||
						const { update_interval } = job.data;
 | 
					 | 
				
			||||||
						agentWs.pushSettingsUpdate(api_id, update_interval);
 | 
					 | 
				
			||||||
					} else if (type === "update_agent") {
 | 
					 | 
				
			||||||
						// Force agent to update by sending WebSocket command
 | 
					 | 
				
			||||||
						const ws = agentWs.getConnectionByApiId(api_id);
 | 
					 | 
				
			||||||
						if (ws && ws.readyState === 1) {
 | 
					 | 
				
			||||||
							// WebSocket.OPEN
 | 
					 | 
				
			||||||
							agentWs.pushUpdateAgent(api_id);
 | 
					 | 
				
			||||||
							console.log(`✅ Update command sent to agent ${api_id}`);
 | 
					 | 
				
			||||||
						} else {
 | 
					 | 
				
			||||||
							console.error(`❌ Agent ${api_id} is not connected`);
 | 
					 | 
				
			||||||
							throw new Error(
 | 
					 | 
				
			||||||
								`Agent ${api_id} is not connected. Cannot send update command.`,
 | 
					 | 
				
			||||||
							);
 | 
					 | 
				
			||||||
						}
 | 
					 | 
				
			||||||
					} else {
 | 
					 | 
				
			||||||
						console.error(`Unknown agent command type: ${type}`);
 | 
					 | 
				
			||||||
					}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
					// Update job history to completed
 | 
					 | 
				
			||||||
					if (historyRecord) {
 | 
					 | 
				
			||||||
						await prisma.job_history.updateMany({
 | 
					 | 
				
			||||||
							where: { job_id: job.id },
 | 
					 | 
				
			||||||
							data: {
 | 
					 | 
				
			||||||
								status: "completed",
 | 
					 | 
				
			||||||
								completed_at: new Date(),
 | 
					 | 
				
			||||||
								updated_at: new Date(),
 | 
					 | 
				
			||||||
							},
 | 
					 | 
				
			||||||
						});
 | 
					 | 
				
			||||||
						console.log(`✅ Marked job as completed in job_history: ${job.id}`);
 | 
					 | 
				
			||||||
					}
 | 
					 | 
				
			||||||
				} catch (error) {
 | 
					 | 
				
			||||||
					// Update job history to failed
 | 
					 | 
				
			||||||
					if (historyRecord) {
 | 
					 | 
				
			||||||
						await prisma.job_history.updateMany({
 | 
					 | 
				
			||||||
							where: { job_id: job.id },
 | 
					 | 
				
			||||||
							data: {
 | 
					 | 
				
			||||||
								status: "failed",
 | 
					 | 
				
			||||||
								error_message: error.message,
 | 
					 | 
				
			||||||
								completed_at: new Date(),
 | 
					 | 
				
			||||||
								updated_at: new Date(),
 | 
					 | 
				
			||||||
							},
 | 
					 | 
				
			||||||
						});
 | 
					 | 
				
			||||||
						console.log(`❌ Marked job as failed in job_history: ${job.id}`);
 | 
					 | 
				
			||||||
					}
 | 
					 | 
				
			||||||
					throw error;
 | 
					 | 
				
			||||||
				}
 | 
									}
 | 
				
			||||||
			},
 | 
								},
 | 
				
			||||||
			workerOptions,
 | 
								workerOptions,
 | 
				
			||||||
@@ -307,7 +221,6 @@ class QueueManager {
 | 
				
			|||||||
				console.log(`✅ Job '${job.id}' in queue '${queueName}' completed.`);
 | 
									console.log(`✅ Job '${job.id}' in queue '${queueName}' completed.`);
 | 
				
			||||||
			});
 | 
								});
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					 | 
				
			||||||
		console.log("✅ Queue events initialized");
 | 
							console.log("✅ Queue events initialized");
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -320,7 +233,6 @@ class QueueManager {
 | 
				
			|||||||
		await this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].schedule();
 | 
							await this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].schedule();
 | 
				
			||||||
		await this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].schedule();
 | 
							await this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].schedule();
 | 
				
			||||||
		await this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].schedule();
 | 
							await this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].schedule();
 | 
				
			||||||
		await this.automations[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK].schedule();
 | 
					 | 
				
			||||||
		await this.automations[QUEUE_NAMES.METRICS_REPORTING].schedule();
 | 
							await this.automations[QUEUE_NAMES.METRICS_REPORTING].schedule();
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -351,12 +263,6 @@ class QueueManager {
 | 
				
			|||||||
		].triggerManual();
 | 
							].triggerManual();
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	async triggerDockerImageUpdateCheck() {
 | 
					 | 
				
			||||||
		return this.automations[
 | 
					 | 
				
			||||||
			QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK
 | 
					 | 
				
			||||||
		].triggerManual();
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	async triggerMetricsReporting() {
 | 
						async triggerMetricsReporting() {
 | 
				
			||||||
		return this.automations[QUEUE_NAMES.METRICS_REPORTING].triggerManual();
 | 
							return this.automations[QUEUE_NAMES.METRICS_REPORTING].triggerManual();
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,179 +0,0 @@
 | 
				
			|||||||
/**
 | 
					 | 
				
			||||||
 * Docker-related utility functions
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Generate a registry link for a Docker image based on its repository and source
 | 
					 | 
				
			||||||
 * Inspired by diun's registry link generation
 | 
					 | 
				
			||||||
 * @param {string} repository - The full repository name (e.g., "ghcr.io/owner/repo")
 | 
					 | 
				
			||||||
 * @param {string} source - The detected source (github, gitlab, docker-hub, etc.)
 | 
					 | 
				
			||||||
 * @returns {string|null} - The URL to the registry page, or null if unknown
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function generateRegistryLink(repository, source) {
 | 
					 | 
				
			||||||
	if (!repository) {
 | 
					 | 
				
			||||||
		return null;
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Parse the domain and path from the repository
 | 
					 | 
				
			||||||
	const parts = repository.split("/");
 | 
					 | 
				
			||||||
	let domain = "";
 | 
					 | 
				
			||||||
	let path = "";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Check if repository has a domain (contains a dot)
 | 
					 | 
				
			||||||
	if (parts[0].includes(".") || parts[0].includes(":")) {
 | 
					 | 
				
			||||||
		domain = parts[0];
 | 
					 | 
				
			||||||
		path = parts.slice(1).join("/");
 | 
					 | 
				
			||||||
	} else {
 | 
					 | 
				
			||||||
		// No domain means Docker Hub
 | 
					 | 
				
			||||||
		domain = "docker.io";
 | 
					 | 
				
			||||||
		path = repository;
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	switch (source) {
 | 
					 | 
				
			||||||
		case "docker-hub":
 | 
					 | 
				
			||||||
		case "docker.io": {
 | 
					 | 
				
			||||||
			// Docker Hub: https://hub.docker.com/r/{path} or https://hub.docker.com/_/{path} for official images
 | 
					 | 
				
			||||||
			// Official images are those without a namespace (e.g., "postgres" not "user/postgres")
 | 
					 | 
				
			||||||
			// or explicitly prefixed with "library/"
 | 
					 | 
				
			||||||
			if (path.startsWith("library/")) {
 | 
					 | 
				
			||||||
				const cleanPath = path.replace("library/", "");
 | 
					 | 
				
			||||||
				return `https://hub.docker.com/_/${cleanPath}`;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			// Check if it's an official image (single part, no slash after removing library/)
 | 
					 | 
				
			||||||
			if (!path.includes("/")) {
 | 
					 | 
				
			||||||
				return `https://hub.docker.com/_/${path}`;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			// Regular user/org image
 | 
					 | 
				
			||||||
			return `https://hub.docker.com/r/${path}`;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		case "github":
 | 
					 | 
				
			||||||
		case "ghcr.io": {
 | 
					 | 
				
			||||||
			// GitHub Container Registry
 | 
					 | 
				
			||||||
			// Format: ghcr.io/{owner}/{package} or ghcr.io/{owner}/{repo}/{package}
 | 
					 | 
				
			||||||
			// URL format: https://github.com/{owner}/{repo}/pkgs/container/{package}
 | 
					 | 
				
			||||||
			if (domain === "ghcr.io" && path) {
 | 
					 | 
				
			||||||
				const pathParts = path.split("/");
 | 
					 | 
				
			||||||
				if (pathParts.length === 2) {
 | 
					 | 
				
			||||||
					// Simple case: ghcr.io/owner/package -> github.com/owner/owner/pkgs/container/package
 | 
					 | 
				
			||||||
					// OR: ghcr.io/owner/repo -> github.com/owner/repo/pkgs/container/{package}
 | 
					 | 
				
			||||||
					// Actually, for 2 parts it's owner/package, and repo is same as owner typically
 | 
					 | 
				
			||||||
					const owner = pathParts[0];
 | 
					 | 
				
			||||||
					const packageName = pathParts[1];
 | 
					 | 
				
			||||||
					return `https://github.com/${owner}/${owner}/pkgs/container/${packageName}`;
 | 
					 | 
				
			||||||
				} else if (pathParts.length >= 3) {
 | 
					 | 
				
			||||||
					// Extended case: ghcr.io/owner/repo/package -> github.com/owner/repo/pkgs/container/package
 | 
					 | 
				
			||||||
					const owner = pathParts[0];
 | 
					 | 
				
			||||||
					const repo = pathParts[1];
 | 
					 | 
				
			||||||
					const packageName = pathParts.slice(2).join("/");
 | 
					 | 
				
			||||||
					return `https://github.com/${owner}/${repo}/pkgs/container/${packageName}`;
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			// Legacy GitHub Packages
 | 
					 | 
				
			||||||
			if (domain === "docker.pkg.github.com" && path) {
 | 
					 | 
				
			||||||
				const pathParts = path.split("/");
 | 
					 | 
				
			||||||
				if (pathParts.length >= 1) {
 | 
					 | 
				
			||||||
					return `https://github.com/${pathParts[0]}/packages`;
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			return null;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		case "gitlab":
 | 
					 | 
				
			||||||
		case "registry.gitlab.com": {
 | 
					 | 
				
			||||||
			// GitLab Container Registry: https://gitlab.com/{path}/container_registry
 | 
					 | 
				
			||||||
			if (path) {
 | 
					 | 
				
			||||||
				return `https://gitlab.com/${path}/container_registry`;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			return null;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		case "google":
 | 
					 | 
				
			||||||
		case "gcr.io": {
 | 
					 | 
				
			||||||
			// Google Container Registry: https://gcr.io/{path}
 | 
					 | 
				
			||||||
			if (domain.includes("gcr.io") || domain.includes("pkg.dev")) {
 | 
					 | 
				
			||||||
				return `https://console.cloud.google.com/gcr/images/${path}`;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			return null;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		case "quay":
 | 
					 | 
				
			||||||
		case "quay.io": {
 | 
					 | 
				
			||||||
			// Quay.io: https://quay.io/repository/{path}
 | 
					 | 
				
			||||||
			if (path) {
 | 
					 | 
				
			||||||
				return `https://quay.io/repository/${path}`;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			return null;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		case "redhat":
 | 
					 | 
				
			||||||
		case "registry.access.redhat.com": {
 | 
					 | 
				
			||||||
			// Red Hat: https://access.redhat.com/containers/#/registry.access.redhat.com/{path}
 | 
					 | 
				
			||||||
			if (path) {
 | 
					 | 
				
			||||||
				return `https://access.redhat.com/containers/#/registry.access.redhat.com/${path}`;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			return null;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		case "azure":
 | 
					 | 
				
			||||||
		case "azurecr.io": {
 | 
					 | 
				
			||||||
			// Azure Container Registry - link to portal
 | 
					 | 
				
			||||||
			// Format: {registry}.azurecr.io/{repository}
 | 
					 | 
				
			||||||
			if (domain.includes("azurecr.io")) {
 | 
					 | 
				
			||||||
				const registryName = domain.split(".")[0];
 | 
					 | 
				
			||||||
				return `https://portal.azure.com/#view/Microsoft_Azure_ContainerRegistries/RepositoryBlade/registryName/${registryName}/repositoryName/${path}`;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			return null;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		case "aws":
 | 
					 | 
				
			||||||
		case "amazonaws.com": {
 | 
					 | 
				
			||||||
			// AWS ECR - link to console
 | 
					 | 
				
			||||||
			// Format: {account}.dkr.ecr.{region}.amazonaws.com/{repository}
 | 
					 | 
				
			||||||
			if (domain.includes("amazonaws.com")) {
 | 
					 | 
				
			||||||
				const domainParts = domain.split(".");
 | 
					 | 
				
			||||||
				const region = domainParts[3]; // Extract region
 | 
					 | 
				
			||||||
				return `https://${region}.console.aws.amazon.com/ecr/repositories/private/${path}`;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			return null;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		case "private":
 | 
					 | 
				
			||||||
			// For private registries, try to construct a basic URL
 | 
					 | 
				
			||||||
			if (domain) {
 | 
					 | 
				
			||||||
				return `https://${domain}`;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			return null;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		default:
 | 
					 | 
				
			||||||
			return null;
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Get a user-friendly display name for a registry source
 | 
					 | 
				
			||||||
 * @param {string} source - The source identifier
 | 
					 | 
				
			||||||
 * @returns {string} - Human-readable source name
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
function getSourceDisplayName(source) {
 | 
					 | 
				
			||||||
	const sourceNames = {
 | 
					 | 
				
			||||||
		"docker-hub": "Docker Hub",
 | 
					 | 
				
			||||||
		github: "GitHub",
 | 
					 | 
				
			||||||
		gitlab: "GitLab",
 | 
					 | 
				
			||||||
		google: "Google",
 | 
					 | 
				
			||||||
		quay: "Quay.io",
 | 
					 | 
				
			||||||
		redhat: "Red Hat",
 | 
					 | 
				
			||||||
		azure: "Azure",
 | 
					 | 
				
			||||||
		aws: "AWS ECR",
 | 
					 | 
				
			||||||
		private: "Private Registry",
 | 
					 | 
				
			||||||
		local: "Local",
 | 
					 | 
				
			||||||
		unknown: "Unknown",
 | 
					 | 
				
			||||||
	};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	return sourceNames[source] || source;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
module.exports = {
 | 
					 | 
				
			||||||
	generateRegistryLink,
 | 
					 | 
				
			||||||
	getSourceDisplayName,
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
@@ -84,20 +84,21 @@ function parse_expiration(expiration_string) {
 | 
				
			|||||||
 * Generate device fingerprint from request data
 | 
					 * Generate device fingerprint from request data
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
function generate_device_fingerprint(req) {
 | 
					function generate_device_fingerprint(req) {
 | 
				
			||||||
	// Use the X-Device-ID header from frontend (unique per browser profile/localStorage)
 | 
						const components = [
 | 
				
			||||||
	const deviceId = req.get("x-device-id");
 | 
							req.get("user-agent") || "",
 | 
				
			||||||
 | 
							req.get("accept-language") || "",
 | 
				
			||||||
 | 
							req.get("accept-encoding") || "",
 | 
				
			||||||
 | 
							req.ip || "",
 | 
				
			||||||
 | 
						];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if (deviceId) {
 | 
						// Create a simple hash of device characteristics
 | 
				
			||||||
		// Hash the device ID for consistent storage format
 | 
						const fingerprint = crypto
 | 
				
			||||||
		return crypto
 | 
							.createHash("sha256")
 | 
				
			||||||
			.createHash("sha256")
 | 
							.update(components.join("|"))
 | 
				
			||||||
			.update(deviceId)
 | 
							.digest("hex")
 | 
				
			||||||
			.digest("hex")
 | 
							.substring(0, 32); // Use first 32 chars for storage efficiency
 | 
				
			||||||
			.substring(0, 32);
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// No device ID - return null (user needs to provide device ID for remember-me)
 | 
						return fingerprint;
 | 
				
			||||||
	return null;
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -48,7 +48,7 @@ WORKDIR /app/backend
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
RUN npm cache clean --force &&\
 | 
					RUN npm cache clean --force &&\
 | 
				
			||||||
    rm -rf node_modules ~/.npm /root/.npm &&\
 | 
					    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 &&\
 | 
					    npm ci --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=0 &&\
 | 
				
			||||||
    PRISMA_CLI_BINARY_TYPE=binary npm run db:generate &&\
 | 
					    PRISMA_CLI_BINARY_TYPE=binary npm run db:generate &&\
 | 
				
			||||||
    npm prune --omit=dev &&\
 | 
					    npm prune --omit=dev &&\
 | 
				
			||||||
    npm cache clean --force
 | 
					    npm cache clean --force
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -21,13 +21,9 @@ WORKDIR /app/frontend
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
COPY frontend/package*.json ./
 | 
					COPY frontend/package*.json ./
 | 
				
			||||||
 | 
					
 | 
				
			||||||
RUN echo "=== Starting npm install ===" &&\
 | 
					RUN npm cache clean --force &&\
 | 
				
			||||||
    npm cache clean --force &&\
 | 
					 | 
				
			||||||
    rm -rf node_modules ~/.npm /root/.npm &&\
 | 
					    rm -rf node_modules ~/.npm /root/.npm &&\
 | 
				
			||||||
    echo "=== npm install ===" &&\
 | 
					    npm install --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=0
 | 
				
			||||||
    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/ ./
 | 
					COPY frontend/ ./
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,5 @@
 | 
				
			|||||||
server {
 | 
					server {
 | 
				
			||||||
    listen 3000;
 | 
					    listen 3000;
 | 
				
			||||||
	listen [::]:3000;
 | 
					 | 
				
			||||||
    server_name localhost;
 | 
					    server_name localhost;
 | 
				
			||||||
    root /usr/share/nginx/html;
 | 
					    root /usr/share/nginx/html;
 | 
				
			||||||
    index index.html;
 | 
					    index index.html;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,7 @@
 | 
				
			|||||||
{
 | 
					{
 | 
				
			||||||
	"name": "patchmon-frontend",
 | 
						"name": "patchmon-frontend",
 | 
				
			||||||
	"private": true,
 | 
						"private": true,
 | 
				
			||||||
	"version": "1.3.2",
 | 
						"version": "1.3.1",
 | 
				
			||||||
	"license": "AGPL-3.0",
 | 
						"license": "AGPL-3.0",
 | 
				
			||||||
	"type": "module",
 | 
						"type": "module",
 | 
				
			||||||
	"scripts": {
 | 
						"scripts": {
 | 
				
			||||||
@@ -27,7 +27,8 @@
 | 
				
			|||||||
		"react-chartjs-2": "^5.2.0",
 | 
							"react-chartjs-2": "^5.2.0",
 | 
				
			||||||
		"react-dom": "^18.3.1",
 | 
							"react-dom": "^18.3.1",
 | 
				
			||||||
		"react-icons": "^5.5.0",
 | 
							"react-icons": "^5.5.0",
 | 
				
			||||||
		"react-router-dom": "^6.30.1"
 | 
							"react-router-dom": "^6.30.1",
 | 
				
			||||||
 | 
							"trianglify": "^4.1.1"
 | 
				
			||||||
	},
 | 
						},
 | 
				
			||||||
	"devDependencies": {
 | 
						"devDependencies": {
 | 
				
			||||||
		"@types/react": "^18.3.14",
 | 
							"@types/react": "^18.3.14",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,7 +8,6 @@ import SettingsLayout from "./components/SettingsLayout";
 | 
				
			|||||||
import { isAuthPhase } from "./constants/authPhases";
 | 
					import { isAuthPhase } from "./constants/authPhases";
 | 
				
			||||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
 | 
					import { AuthProvider, useAuth } from "./contexts/AuthContext";
 | 
				
			||||||
import { ColorThemeProvider } from "./contexts/ColorThemeContext";
 | 
					import { ColorThemeProvider } from "./contexts/ColorThemeContext";
 | 
				
			||||||
import { SettingsProvider } from "./contexts/SettingsContext";
 | 
					 | 
				
			||||||
import { ThemeProvider } from "./contexts/ThemeContext";
 | 
					import { ThemeProvider } from "./contexts/ThemeContext";
 | 
				
			||||||
import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
 | 
					import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -29,8 +28,6 @@ const DockerContainerDetail = lazy(
 | 
				
			|||||||
);
 | 
					);
 | 
				
			||||||
const DockerImageDetail = lazy(() => import("./pages/docker/ImageDetail"));
 | 
					const DockerImageDetail = lazy(() => import("./pages/docker/ImageDetail"));
 | 
				
			||||||
const DockerHostDetail = lazy(() => import("./pages/docker/HostDetail"));
 | 
					const DockerHostDetail = lazy(() => import("./pages/docker/HostDetail"));
 | 
				
			||||||
const DockerVolumeDetail = lazy(() => import("./pages/docker/VolumeDetail"));
 | 
					 | 
				
			||||||
const DockerNetworkDetail = lazy(() => import("./pages/docker/NetworkDetail"));
 | 
					 | 
				
			||||||
const AlertChannels = lazy(() => import("./pages/settings/AlertChannels"));
 | 
					const AlertChannels = lazy(() => import("./pages/settings/AlertChannels"));
 | 
				
			||||||
const Integrations = lazy(() => import("./pages/settings/Integrations"));
 | 
					const Integrations = lazy(() => import("./pages/settings/Integrations"));
 | 
				
			||||||
const Notifications = lazy(() => import("./pages/settings/Notifications"));
 | 
					const Notifications = lazy(() => import("./pages/settings/Notifications"));
 | 
				
			||||||
@@ -197,26 +194,6 @@ function AppRoutes() {
 | 
				
			|||||||
						</ProtectedRoute>
 | 
											</ProtectedRoute>
 | 
				
			||||||
					}
 | 
										}
 | 
				
			||||||
				/>
 | 
									/>
 | 
				
			||||||
				<Route
 | 
					 | 
				
			||||||
					path="/docker/volumes/:id"
 | 
					 | 
				
			||||||
					element={
 | 
					 | 
				
			||||||
						<ProtectedRoute requirePermission="can_view_reports">
 | 
					 | 
				
			||||||
							<Layout>
 | 
					 | 
				
			||||||
								<DockerVolumeDetail />
 | 
					 | 
				
			||||||
							</Layout>
 | 
					 | 
				
			||||||
						</ProtectedRoute>
 | 
					 | 
				
			||||||
					}
 | 
					 | 
				
			||||||
				/>
 | 
					 | 
				
			||||||
				<Route
 | 
					 | 
				
			||||||
					path="/docker/networks/:id"
 | 
					 | 
				
			||||||
					element={
 | 
					 | 
				
			||||||
						<ProtectedRoute requirePermission="can_view_reports">
 | 
					 | 
				
			||||||
							<Layout>
 | 
					 | 
				
			||||||
								<DockerNetworkDetail />
 | 
					 | 
				
			||||||
							</Layout>
 | 
					 | 
				
			||||||
						</ProtectedRoute>
 | 
					 | 
				
			||||||
					}
 | 
					 | 
				
			||||||
				/>
 | 
					 | 
				
			||||||
				<Route
 | 
									<Route
 | 
				
			||||||
					path="/users"
 | 
										path="/users"
 | 
				
			||||||
					element={
 | 
										element={
 | 
				
			||||||
@@ -450,19 +427,17 @@ function AppRoutes() {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
function App() {
 | 
					function App() {
 | 
				
			||||||
	return (
 | 
						return (
 | 
				
			||||||
		<AuthProvider>
 | 
							<ThemeProvider>
 | 
				
			||||||
			<ThemeProvider>
 | 
								<ColorThemeProvider>
 | 
				
			||||||
				<SettingsProvider>
 | 
									<AuthProvider>
 | 
				
			||||||
					<ColorThemeProvider>
 | 
										<UpdateNotificationProvider>
 | 
				
			||||||
						<UpdateNotificationProvider>
 | 
											<LogoProvider>
 | 
				
			||||||
							<LogoProvider>
 | 
												<AppRoutes />
 | 
				
			||||||
								<AppRoutes />
 | 
											</LogoProvider>
 | 
				
			||||||
							</LogoProvider>
 | 
										</UpdateNotificationProvider>
 | 
				
			||||||
						</UpdateNotificationProvider>
 | 
									</AuthProvider>
 | 
				
			||||||
					</ColorThemeProvider>
 | 
								</ColorThemeProvider>
 | 
				
			||||||
				</SettingsProvider>
 | 
							</ThemeProvider>
 | 
				
			||||||
			</ThemeProvider>
 | 
					 | 
				
			||||||
		</AuthProvider>
 | 
					 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -28,6 +28,7 @@ import {
 | 
				
			|||||||
import { useCallback, useEffect, useRef, useState } from "react";
 | 
					import { useCallback, useEffect, useRef, useState } from "react";
 | 
				
			||||||
import { FaReddit, FaYoutube } from "react-icons/fa";
 | 
					import { FaReddit, FaYoutube } from "react-icons/fa";
 | 
				
			||||||
import { Link, useLocation, useNavigate } from "react-router-dom";
 | 
					import { Link, useLocation, useNavigate } from "react-router-dom";
 | 
				
			||||||
 | 
					import trianglify from "trianglify";
 | 
				
			||||||
import { useAuth } from "../contexts/AuthContext";
 | 
					import { useAuth } from "../contexts/AuthContext";
 | 
				
			||||||
import { useColorTheme } from "../contexts/ColorThemeContext";
 | 
					import { useColorTheme } from "../contexts/ColorThemeContext";
 | 
				
			||||||
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
 | 
					import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
 | 
				
			||||||
@@ -236,93 +237,31 @@ const Layout = ({ children }) => {
 | 
				
			|||||||
		navigate("/hosts?action=add");
 | 
							navigate("/hosts?action=add");
 | 
				
			||||||
	};
 | 
						};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Generate clean radial gradient background with subtle triangular accents for dark mode
 | 
						// Generate Trianglify background for dark mode
 | 
				
			||||||
	useEffect(() => {
 | 
						useEffect(() => {
 | 
				
			||||||
		const generateBackground = () => {
 | 
							const generateBackground = () => {
 | 
				
			||||||
			if (
 | 
								if (
 | 
				
			||||||
				!bgCanvasRef.current ||
 | 
									bgCanvasRef.current &&
 | 
				
			||||||
				!themeConfig?.login ||
 | 
									themeConfig?.login &&
 | 
				
			||||||
				!document.documentElement.classList.contains("dark")
 | 
									document.documentElement.classList.contains("dark")
 | 
				
			||||||
			) {
 | 
								) {
 | 
				
			||||||
				return;
 | 
									// Get current date as seed for daily variation
 | 
				
			||||||
			}
 | 
									const today = new Date();
 | 
				
			||||||
 | 
									const dateSeed = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			const canvas = bgCanvasRef.current;
 | 
									// Generate pattern with selected theme configuration
 | 
				
			||||||
			canvas.width = window.innerWidth;
 | 
									const pattern = trianglify({
 | 
				
			||||||
			canvas.height = window.innerHeight;
 | 
										width: window.innerWidth,
 | 
				
			||||||
			const ctx = canvas.getContext("2d");
 | 
										height: window.innerHeight,
 | 
				
			||||||
 | 
										cellSize: themeConfig.login.cellSize,
 | 
				
			||||||
 | 
										variance: themeConfig.login.variance,
 | 
				
			||||||
 | 
										seed: dateSeed,
 | 
				
			||||||
 | 
										xColors: themeConfig.login.xColors,
 | 
				
			||||||
 | 
										yColors: themeConfig.login.yColors,
 | 
				
			||||||
 | 
									});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			// Get theme colors - pick first color from each palette
 | 
									// Render to canvas
 | 
				
			||||||
			const xColors = themeConfig.login.xColors || [
 | 
									pattern.toCanvas(bgCanvasRef.current);
 | 
				
			||||||
				"#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();
 | 
					 | 
				
			||||||
					}
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,8 +1,17 @@
 | 
				
			|||||||
 | 
					import { useQuery } from "@tanstack/react-query";
 | 
				
			||||||
import { useEffect } from "react";
 | 
					import { useEffect } from "react";
 | 
				
			||||||
import { useSettings } from "../contexts/SettingsContext";
 | 
					import { isAuthReady } from "../constants/authPhases";
 | 
				
			||||||
 | 
					import { useAuth } from "../contexts/AuthContext";
 | 
				
			||||||
 | 
					import { settingsAPI } from "../utils/api";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const LogoProvider = ({ children }) => {
 | 
					const LogoProvider = ({ children }) => {
 | 
				
			||||||
	const { settings } = useSettings();
 | 
						const { authPhase, isAuthenticated } = useAuth();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						const { data: settings } = useQuery({
 | 
				
			||||||
 | 
							queryKey: ["settings"],
 | 
				
			||||||
 | 
							queryFn: () => settingsAPI.get().then((res) => res.data),
 | 
				
			||||||
 | 
							enabled: isAuthReady(authPhase, isAuthenticated()),
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	useEffect(() => {
 | 
						useEffect(() => {
 | 
				
			||||||
		// Use custom favicon or fallback to default
 | 
							// Use custom favicon or fallback to default
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,14 @@
 | 
				
			|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 | 
					import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 | 
				
			||||||
import { AlertCircle, Image, RotateCcw, Upload, X } from "lucide-react";
 | 
					import {
 | 
				
			||||||
 | 
						AlertCircle,
 | 
				
			||||||
 | 
						Image,
 | 
				
			||||||
 | 
						Palette,
 | 
				
			||||||
 | 
						RotateCcw,
 | 
				
			||||||
 | 
						Upload,
 | 
				
			||||||
 | 
						X,
 | 
				
			||||||
 | 
					} from "lucide-react";
 | 
				
			||||||
import { useState } from "react";
 | 
					import { useState } from "react";
 | 
				
			||||||
 | 
					import { THEME_PRESETS, useColorTheme } from "../../contexts/ColorThemeContext";
 | 
				
			||||||
import { settingsAPI } from "../../utils/api";
 | 
					import { settingsAPI } from "../../utils/api";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const BrandingTab = () => {
 | 
					const BrandingTab = () => {
 | 
				
			||||||
@@ -12,6 +20,7 @@ const BrandingTab = () => {
 | 
				
			|||||||
	});
 | 
						});
 | 
				
			||||||
	const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
 | 
						const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
 | 
				
			||||||
	const [selectedLogoType, setSelectedLogoType] = useState("dark");
 | 
						const [selectedLogoType, setSelectedLogoType] = useState("dark");
 | 
				
			||||||
 | 
						const { colorTheme, setColorTheme } = useColorTheme();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	const queryClient = useQueryClient();
 | 
						const queryClient = useQueryClient();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -75,6 +84,22 @@ const BrandingTab = () => {
 | 
				
			|||||||
		},
 | 
							},
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Theme update mutation
 | 
				
			||||||
 | 
						const updateThemeMutation = useMutation({
 | 
				
			||||||
 | 
							mutationFn: (theme) => settingsAPI.update({ colorTheme: theme }),
 | 
				
			||||||
 | 
							onSuccess: (_data, theme) => {
 | 
				
			||||||
 | 
								queryClient.invalidateQueries(["settings"]);
 | 
				
			||||||
 | 
								setColorTheme(theme);
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							onError: (error) => {
 | 
				
			||||||
 | 
								console.error("Update theme error:", error);
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						const handleThemeChange = (theme) => {
 | 
				
			||||||
 | 
							updateThemeMutation.mutate(theme);
 | 
				
			||||||
 | 
						};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if (isLoading) {
 | 
						if (isLoading) {
 | 
				
			||||||
		return (
 | 
							return (
 | 
				
			||||||
			<div className="flex items-center justify-center h-64">
 | 
								<div className="flex items-center justify-center h-64">
 | 
				
			||||||
@@ -112,11 +137,93 @@ const BrandingTab = () => {
 | 
				
			|||||||
					</h2>
 | 
										</h2>
 | 
				
			||||||
				</div>
 | 
									</div>
 | 
				
			||||||
				<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
 | 
									<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
 | 
				
			||||||
					Customize your PatchMon installation with custom logos and favicon.
 | 
										Customize your PatchMon installation with custom logos, favicon, and
 | 
				
			||||||
					These will be displayed throughout the application.
 | 
										color themes. These will be displayed throughout the application.
 | 
				
			||||||
				</p>
 | 
									</p>
 | 
				
			||||||
			</div>
 | 
								</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 */}
 | 
								{/* Logo Section Header */}
 | 
				
			||||||
			<div className="flex items-center mb-4">
 | 
								<div className="flex items-center mb-4">
 | 
				
			||||||
				<Image className="h-5 w-5 text-primary-600 mr-2" />
 | 
									<Image className="h-5 w-5 text-primary-600 mr-2" />
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -91,29 +91,10 @@ export const AuthProvider = ({ children }) => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	const login = async (username, password) => {
 | 
						const login = async (username, password) => {
 | 
				
			||||||
		try {
 | 
							try {
 | 
				
			||||||
			// Get or generate device ID for TFA remember-me
 | 
					 | 
				
			||||||
			let deviceId = localStorage.getItem("device_id");
 | 
					 | 
				
			||||||
			if (!deviceId) {
 | 
					 | 
				
			||||||
				if (typeof crypto !== "undefined" && crypto.randomUUID) {
 | 
					 | 
				
			||||||
					deviceId = crypto.randomUUID();
 | 
					 | 
				
			||||||
				} else {
 | 
					 | 
				
			||||||
					deviceId = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
 | 
					 | 
				
			||||||
						/[xy]/g,
 | 
					 | 
				
			||||||
						(c) => {
 | 
					 | 
				
			||||||
							const r = (Math.random() * 16) | 0;
 | 
					 | 
				
			||||||
							const v = c === "x" ? r : (r & 0x3) | 0x8;
 | 
					 | 
				
			||||||
							return v.toString(16);
 | 
					 | 
				
			||||||
						},
 | 
					 | 
				
			||||||
					);
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
				localStorage.setItem("device_id", deviceId);
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			const response = await fetch("/api/v1/auth/login", {
 | 
								const response = await fetch("/api/v1/auth/login", {
 | 
				
			||||||
				method: "POST",
 | 
									method: "POST",
 | 
				
			||||||
				headers: {
 | 
									headers: {
 | 
				
			||||||
					"Content-Type": "application/json",
 | 
										"Content-Type": "application/json",
 | 
				
			||||||
					"X-Device-ID": deviceId,
 | 
					 | 
				
			||||||
				},
 | 
									},
 | 
				
			||||||
				body: JSON.stringify({ username, password }),
 | 
									body: JSON.stringify({ username, password }),
 | 
				
			||||||
			});
 | 
								});
 | 
				
			||||||
@@ -138,9 +119,6 @@ export const AuthProvider = ({ children }) => {
 | 
				
			|||||||
					setPermissions(userPermissions);
 | 
										setPermissions(userPermissions);
 | 
				
			||||||
				}
 | 
									}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
				// Note: User preferences will be automatically fetched by ColorThemeContext
 | 
					 | 
				
			||||||
				// when the component mounts, so no need to invalidate here
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				return { success: true };
 | 
									return { success: true };
 | 
				
			||||||
			} else {
 | 
								} else {
 | 
				
			||||||
				// Handle HTTP error responses (like 500 CORS errors)
 | 
									// Handle HTTP error responses (like 500 CORS errors)
 | 
				
			||||||
@@ -227,19 +205,8 @@ export const AuthProvider = ({ children }) => {
 | 
				
			|||||||
			const data = await response.json();
 | 
								const data = await response.json();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			if (response.ok) {
 | 
								if (response.ok) {
 | 
				
			||||||
				// Validate that we received user data with expected fields
 | 
					 | 
				
			||||||
				if (!data.user || !data.user.id) {
 | 
					 | 
				
			||||||
					console.error("Invalid user data in response:", data);
 | 
					 | 
				
			||||||
					return {
 | 
					 | 
				
			||||||
						success: false,
 | 
					 | 
				
			||||||
						error: "Invalid response from server",
 | 
					 | 
				
			||||||
					};
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				// Update both state and localStorage atomically
 | 
					 | 
				
			||||||
				setUser(data.user);
 | 
									setUser(data.user);
 | 
				
			||||||
				localStorage.setItem("user", JSON.stringify(data.user));
 | 
									localStorage.setItem("user", JSON.stringify(data.user));
 | 
				
			||||||
 | 
					 | 
				
			||||||
				return { success: true, user: data.user };
 | 
									return { success: true, user: data.user };
 | 
				
			||||||
			} else {
 | 
								} else {
 | 
				
			||||||
				// Handle HTTP error responses (like 500 CORS errors)
 | 
									// Handle HTTP error responses (like 500 CORS errors)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,15 +1,4 @@
 | 
				
			|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
 | 
					import { createContext, useContext, useEffect, useState } from "react";
 | 
				
			||||||
import {
 | 
					 | 
				
			||||||
	createContext,
 | 
					 | 
				
			||||||
	useCallback,
 | 
					 | 
				
			||||||
	useContext,
 | 
					 | 
				
			||||||
	useEffect,
 | 
					 | 
				
			||||||
	useMemo,
 | 
					 | 
				
			||||||
	useRef,
 | 
					 | 
				
			||||||
	useState,
 | 
					 | 
				
			||||||
} from "react";
 | 
					 | 
				
			||||||
import { userPreferencesAPI } from "../utils/api";
 | 
					 | 
				
			||||||
import { useAuth } from "./AuthContext";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const ColorThemeContext = createContext();
 | 
					const ColorThemeContext = createContext();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -132,108 +121,62 @@ export const THEME_PRESETS = {
 | 
				
			|||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const ColorThemeProvider = ({ children }) => {
 | 
					export const ColorThemeProvider = ({ children }) => {
 | 
				
			||||||
	const queryClient = useQueryClient();
 | 
						const [colorTheme, setColorTheme] = useState("default");
 | 
				
			||||||
	const lastThemeRef = useRef(null);
 | 
						const [isLoading, setIsLoading] = useState(true);
 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Use reactive authentication state from AuthContext
 | 
					 | 
				
			||||||
	// This ensures the query re-enables when user logs in
 | 
					 | 
				
			||||||
	const { user } = useAuth();
 | 
					 | 
				
			||||||
	const isAuthenticated = !!user;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Source of truth: Database (via userPreferences query)
 | 
					 | 
				
			||||||
	// localStorage is only used as a temporary cache until DB loads
 | 
					 | 
				
			||||||
	// Only fetch if user is authenticated to avoid 401 errors on login page
 | 
					 | 
				
			||||||
	const { data: userPreferences, isLoading: preferencesLoading } = useQuery({
 | 
					 | 
				
			||||||
		queryKey: ["userPreferences"],
 | 
					 | 
				
			||||||
		queryFn: () => userPreferencesAPI.get().then((res) => res.data),
 | 
					 | 
				
			||||||
		enabled: isAuthenticated, // Only run query if user is authenticated
 | 
					 | 
				
			||||||
		retry: 2,
 | 
					 | 
				
			||||||
		staleTime: 5 * 60 * 1000, // 5 minutes
 | 
					 | 
				
			||||||
		refetchOnWindowFocus: true, // Refetch when user returns to tab
 | 
					 | 
				
			||||||
	});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Get theme from database (source of truth), fallback to user object from login, then localStorage cache, then default
 | 
					 | 
				
			||||||
	// Memoize to prevent recalculation on every render
 | 
					 | 
				
			||||||
	const colorThemeValue = useMemo(() => {
 | 
					 | 
				
			||||||
		return (
 | 
					 | 
				
			||||||
			userPreferences?.color_theme ||
 | 
					 | 
				
			||||||
			user?.color_theme ||
 | 
					 | 
				
			||||||
			localStorage.getItem("colorTheme") ||
 | 
					 | 
				
			||||||
			"cyber_blue"
 | 
					 | 
				
			||||||
		);
 | 
					 | 
				
			||||||
	}, [userPreferences?.color_theme, user?.color_theme]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Only update state if the theme value actually changed (prevent loops)
 | 
					 | 
				
			||||||
	const [colorTheme, setColorTheme] = useState(() => colorThemeValue);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Fetch theme from settings on mount
 | 
				
			||||||
	useEffect(() => {
 | 
						useEffect(() => {
 | 
				
			||||||
		// Only update if the value actually changed from what we last saw (prevent loops)
 | 
							const fetchTheme = async () => {
 | 
				
			||||||
		if (colorThemeValue !== lastThemeRef.current) {
 | 
					 | 
				
			||||||
			setColorTheme(colorThemeValue);
 | 
					 | 
				
			||||||
			lastThemeRef.current = colorThemeValue;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}, [colorThemeValue]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	const isLoading = preferencesLoading;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Sync localStorage cache when DB data is available (for offline/performance)
 | 
					 | 
				
			||||||
	useEffect(() => {
 | 
					 | 
				
			||||||
		if (userPreferences?.color_theme) {
 | 
					 | 
				
			||||||
			localStorage.setItem("colorTheme", userPreferences.color_theme);
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}, [userPreferences?.color_theme]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	const updateColorTheme = useCallback(
 | 
					 | 
				
			||||||
		async (theme) => {
 | 
					 | 
				
			||||||
			// Store previous theme for potential revert
 | 
					 | 
				
			||||||
			const previousTheme = colorTheme;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			// Immediately update state for instant UI feedback
 | 
					 | 
				
			||||||
			setColorTheme(theme);
 | 
					 | 
				
			||||||
			lastThemeRef.current = theme;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			// Also update localStorage cache
 | 
					 | 
				
			||||||
			localStorage.setItem("colorTheme", theme);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			// Save to backend (source of truth)
 | 
					 | 
				
			||||||
			try {
 | 
								try {
 | 
				
			||||||
				await userPreferencesAPI.update({ color_theme: theme });
 | 
									// Check localStorage first for unauthenticated pages (login)
 | 
				
			||||||
 | 
									const cachedTheme = localStorage.getItem("colorTheme");
 | 
				
			||||||
 | 
									if (cachedTheme) {
 | 
				
			||||||
 | 
										setColorTheme(cachedTheme);
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
				// Invalidate and refetch user preferences to ensure sync across tabs/browsers
 | 
									// Try to fetch from API (will fail on login page, that's ok)
 | 
				
			||||||
				await queryClient.invalidateQueries({ queryKey: ["userPreferences"] });
 | 
									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) {
 | 
								} catch (error) {
 | 
				
			||||||
				console.error("Failed to save color theme preference:", error);
 | 
									console.error("Error loading color theme:", error);
 | 
				
			||||||
				// Revert to previous theme if save failed
 | 
								} finally {
 | 
				
			||||||
				setColorTheme(previousTheme);
 | 
									setIsLoading(false);
 | 
				
			||||||
				lastThemeRef.current = previousTheme;
 | 
					 | 
				
			||||||
				localStorage.setItem("colorTheme", previousTheme);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				// Invalidate to refresh from DB
 | 
					 | 
				
			||||||
				await queryClient.invalidateQueries({ queryKey: ["userPreferences"] });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				// Show error to user if possible (could add toast notification here)
 | 
					 | 
				
			||||||
				throw error; // Re-throw so calling code can handle it
 | 
					 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		},
 | 
							};
 | 
				
			||||||
		[colorTheme, queryClient],
 | 
					 | 
				
			||||||
	);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Memoize themeConfig to prevent unnecessary re-renders
 | 
							fetchTheme();
 | 
				
			||||||
	const themeConfig = useMemo(
 | 
						}, []);
 | 
				
			||||||
		() => THEME_PRESETS[colorTheme] || THEME_PRESETS.default,
 | 
					 | 
				
			||||||
		[colorTheme],
 | 
					 | 
				
			||||||
	);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Memoize the context value to prevent unnecessary re-renders
 | 
						const updateColorTheme = (theme) => {
 | 
				
			||||||
	const value = useMemo(
 | 
							setColorTheme(theme);
 | 
				
			||||||
		() => ({
 | 
							localStorage.setItem("colorTheme", theme);
 | 
				
			||||||
			colorTheme,
 | 
						};
 | 
				
			||||||
			setColorTheme: updateColorTheme,
 | 
					
 | 
				
			||||||
			themeConfig,
 | 
						const value = {
 | 
				
			||||||
			isLoading,
 | 
							colorTheme,
 | 
				
			||||||
		}),
 | 
							setColorTheme: updateColorTheme,
 | 
				
			||||||
		[colorTheme, themeConfig, isLoading, updateColorTheme],
 | 
							themeConfig: THEME_PRESETS[colorTheme] || THEME_PRESETS.default,
 | 
				
			||||||
	);
 | 
							isLoading,
 | 
				
			||||||
 | 
						};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return (
 | 
						return (
 | 
				
			||||||
		<ColorThemeContext.Provider value={value}>
 | 
							<ColorThemeContext.Provider value={value}>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,45 +0,0 @@
 | 
				
			|||||||
import { useQuery } from "@tanstack/react-query";
 | 
					 | 
				
			||||||
import { createContext, useContext } from "react";
 | 
					 | 
				
			||||||
import { isAuthReady } from "../constants/authPhases";
 | 
					 | 
				
			||||||
import { settingsAPI } from "../utils/api";
 | 
					 | 
				
			||||||
import { useAuth } from "./AuthContext";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const SettingsContext = createContext();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const useSettings = () => {
 | 
					 | 
				
			||||||
	const context = useContext(SettingsContext);
 | 
					 | 
				
			||||||
	if (!context) {
 | 
					 | 
				
			||||||
		throw new Error("useSettings must be used within a SettingsProvider");
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	return context;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const SettingsProvider = ({ children }) => {
 | 
					 | 
				
			||||||
	const { authPhase, isAuthenticated } = useAuth();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	const {
 | 
					 | 
				
			||||||
		data: settings,
 | 
					 | 
				
			||||||
		isLoading,
 | 
					 | 
				
			||||||
		error,
 | 
					 | 
				
			||||||
		refetch,
 | 
					 | 
				
			||||||
	} = useQuery({
 | 
					 | 
				
			||||||
		queryKey: ["settings"],
 | 
					 | 
				
			||||||
		queryFn: () => settingsAPI.get().then((res) => res.data),
 | 
					 | 
				
			||||||
		staleTime: 5 * 60 * 1000, // Settings stay fresh for 5 minutes
 | 
					 | 
				
			||||||
		refetchOnWindowFocus: false,
 | 
					 | 
				
			||||||
		enabled: isAuthReady(authPhase, isAuthenticated()),
 | 
					 | 
				
			||||||
	});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	const value = {
 | 
					 | 
				
			||||||
		settings,
 | 
					 | 
				
			||||||
		isLoading,
 | 
					 | 
				
			||||||
		error,
 | 
					 | 
				
			||||||
		refetch,
 | 
					 | 
				
			||||||
	};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	return (
 | 
					 | 
				
			||||||
		<SettingsContext.Provider value={value}>
 | 
					 | 
				
			||||||
			{children}
 | 
					 | 
				
			||||||
		</SettingsContext.Provider>
 | 
					 | 
				
			||||||
	);
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
@@ -1,7 +1,4 @@
 | 
				
			|||||||
import { useQuery } from "@tanstack/react-query";
 | 
					 | 
				
			||||||
import { createContext, useContext, useEffect, useState } from "react";
 | 
					import { createContext, useContext, useEffect, useState } from "react";
 | 
				
			||||||
import { userPreferencesAPI } from "../utils/api";
 | 
					 | 
				
			||||||
import { useAuth } from "./AuthContext";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const ThemeContext = createContext();
 | 
					const ThemeContext = createContext();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -15,7 +12,7 @@ export const useTheme = () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const ThemeProvider = ({ children }) => {
 | 
					export const ThemeProvider = ({ children }) => {
 | 
				
			||||||
	const [theme, setTheme] = useState(() => {
 | 
						const [theme, setTheme] = useState(() => {
 | 
				
			||||||
		// Check localStorage first for immediate render
 | 
							// Check localStorage first, then system preference
 | 
				
			||||||
		const savedTheme = localStorage.getItem("theme");
 | 
							const savedTheme = localStorage.getItem("theme");
 | 
				
			||||||
		if (savedTheme) {
 | 
							if (savedTheme) {
 | 
				
			||||||
			return savedTheme;
 | 
								return savedTheme;
 | 
				
			||||||
@@ -27,30 +24,6 @@ export const ThemeProvider = ({ children }) => {
 | 
				
			|||||||
		return "light";
 | 
							return "light";
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Use reactive authentication state from AuthContext
 | 
					 | 
				
			||||||
	// This ensures the query re-enables when user logs in
 | 
					 | 
				
			||||||
	const { user } = useAuth();
 | 
					 | 
				
			||||||
	const isAuthenticated = !!user;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Fetch user preferences from backend (only if authenticated)
 | 
					 | 
				
			||||||
	const { data: userPreferences } = useQuery({
 | 
					 | 
				
			||||||
		queryKey: ["userPreferences"],
 | 
					 | 
				
			||||||
		queryFn: () => userPreferencesAPI.get().then((res) => res.data),
 | 
					 | 
				
			||||||
		enabled: isAuthenticated, // Only run query if user is authenticated
 | 
					 | 
				
			||||||
		retry: 1,
 | 
					 | 
				
			||||||
		staleTime: 5 * 60 * 1000, // 5 minutes
 | 
					 | 
				
			||||||
	});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Sync with user preferences from backend or user object from login
 | 
					 | 
				
			||||||
	useEffect(() => {
 | 
					 | 
				
			||||||
		const preferredTheme =
 | 
					 | 
				
			||||||
			userPreferences?.theme_preference || user?.theme_preference;
 | 
					 | 
				
			||||||
		if (preferredTheme) {
 | 
					 | 
				
			||||||
			setTheme(preferredTheme);
 | 
					 | 
				
			||||||
			localStorage.setItem("theme", preferredTheme);
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}, [userPreferences, user?.theme_preference]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	useEffect(() => {
 | 
						useEffect(() => {
 | 
				
			||||||
		// Apply theme to document
 | 
							// Apply theme to document
 | 
				
			||||||
		if (theme === "dark") {
 | 
							if (theme === "dark") {
 | 
				
			||||||
@@ -63,17 +36,8 @@ export const ThemeProvider = ({ children }) => {
 | 
				
			|||||||
		localStorage.setItem("theme", theme);
 | 
							localStorage.setItem("theme", theme);
 | 
				
			||||||
	}, [theme]);
 | 
						}, [theme]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	const toggleTheme = async () => {
 | 
						const toggleTheme = () => {
 | 
				
			||||||
		const newTheme = theme === "light" ? "dark" : "light";
 | 
							setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
 | 
				
			||||||
		setTheme(newTheme);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		// Save to backend
 | 
					 | 
				
			||||||
		try {
 | 
					 | 
				
			||||||
			await userPreferencesAPI.update({ theme_preference: newTheme });
 | 
					 | 
				
			||||||
		} catch (error) {
 | 
					 | 
				
			||||||
			console.error("Failed to save theme preference:", error);
 | 
					 | 
				
			||||||
			// Theme is already set locally, so user still sees the change
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	};
 | 
						};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	const value = {
 | 
						const value = {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,8 @@
 | 
				
			|||||||
 | 
					import { useQuery } from "@tanstack/react-query";
 | 
				
			||||||
import { createContext, useContext, useState } from "react";
 | 
					import { createContext, useContext, useState } from "react";
 | 
				
			||||||
import { useSettings } from "./SettingsContext";
 | 
					import { isAuthReady } from "../constants/authPhases";
 | 
				
			||||||
 | 
					import { settingsAPI } from "../utils/api";
 | 
				
			||||||
 | 
					import { useAuth } from "./AuthContext";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const UpdateNotificationContext = createContext();
 | 
					const UpdateNotificationContext = createContext();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -15,7 +18,17 @@ export const useUpdateNotification = () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const UpdateNotificationProvider = ({ children }) => {
 | 
					export const UpdateNotificationProvider = ({ children }) => {
 | 
				
			||||||
	const [dismissed, setDismissed] = useState(false);
 | 
						const [dismissed, setDismissed] = useState(false);
 | 
				
			||||||
	const { settings, isLoading: settingsLoading } = useSettings();
 | 
						const { authPhase, isAuthenticated } = useAuth();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Ensure settings are loaded - but only after auth is fully ready
 | 
				
			||||||
 | 
						// This reads cached update info from backend (updated by scheduler)
 | 
				
			||||||
 | 
						const { data: settings, isLoading: settingsLoading } = useQuery({
 | 
				
			||||||
 | 
							queryKey: ["settings"],
 | 
				
			||||||
 | 
							queryFn: () => settingsAPI.get().then((res) => res.data),
 | 
				
			||||||
 | 
							staleTime: 5 * 60 * 1000, // Settings stay fresh for 5 minutes
 | 
				
			||||||
 | 
							refetchOnWindowFocus: false,
 | 
				
			||||||
 | 
							enabled: isAuthReady(authPhase, isAuthenticated()),
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Read cached update information from settings (no GitHub API calls)
 | 
						// Read cached update information from settings (no GitHub API calls)
 | 
				
			||||||
	// The backend scheduler updates this data periodically
 | 
						// The backend scheduler updates this data periodically
 | 
				
			||||||
 
 | 
				
			|||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -12,7 +12,6 @@ import {
 | 
				
			|||||||
	Copy,
 | 
						Copy,
 | 
				
			||||||
	Cpu,
 | 
						Cpu,
 | 
				
			||||||
	Database,
 | 
						Database,
 | 
				
			||||||
	Download,
 | 
					 | 
				
			||||||
	Eye,
 | 
						Eye,
 | 
				
			||||||
	EyeOff,
 | 
						EyeOff,
 | 
				
			||||||
	HardDrive,
 | 
						HardDrive,
 | 
				
			||||||
@@ -54,8 +53,6 @@ const HostDetail = () => {
 | 
				
			|||||||
	const [historyLimit] = useState(10);
 | 
						const [historyLimit] = useState(10);
 | 
				
			||||||
	const [notes, setNotes] = useState("");
 | 
						const [notes, setNotes] = useState("");
 | 
				
			||||||
	const [notesMessage, setNotesMessage] = useState({ text: "", type: "" });
 | 
						const [notesMessage, setNotesMessage] = useState({ text: "", type: "" });
 | 
				
			||||||
	const [updateMessage, setUpdateMessage] = useState({ text: "", jobId: "" });
 | 
					 | 
				
			||||||
	const [reportMessage, setReportMessage] = useState({ text: "", jobId: "" });
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	const {
 | 
						const {
 | 
				
			||||||
		data: host,
 | 
							data: host,
 | 
				
			||||||
@@ -190,57 +187,6 @@ const HostDetail = () => {
 | 
				
			|||||||
		},
 | 
							},
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Force agent update mutation
 | 
					 | 
				
			||||||
	const forceAgentUpdateMutation = useMutation({
 | 
					 | 
				
			||||||
		mutationFn: () =>
 | 
					 | 
				
			||||||
			adminHostsAPI.forceAgentUpdate(hostId).then((res) => res.data),
 | 
					 | 
				
			||||||
		onSuccess: (data) => {
 | 
					 | 
				
			||||||
			queryClient.invalidateQueries(["host", hostId]);
 | 
					 | 
				
			||||||
			queryClient.invalidateQueries(["hosts"]);
 | 
					 | 
				
			||||||
			// Show success message with job ID
 | 
					 | 
				
			||||||
			if (data?.jobId) {
 | 
					 | 
				
			||||||
				setUpdateMessage({
 | 
					 | 
				
			||||||
					text: "Update queued successfully",
 | 
					 | 
				
			||||||
					jobId: data.jobId,
 | 
					 | 
				
			||||||
				});
 | 
					 | 
				
			||||||
				// Clear message after 5 seconds
 | 
					 | 
				
			||||||
				setTimeout(() => setUpdateMessage({ text: "", jobId: "" }), 5000);
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
		},
 | 
					 | 
				
			||||||
		onError: (error) => {
 | 
					 | 
				
			||||||
			setUpdateMessage({
 | 
					 | 
				
			||||||
				text: error.response?.data?.error || "Failed to queue update",
 | 
					 | 
				
			||||||
				jobId: "",
 | 
					 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
			setTimeout(() => setUpdateMessage({ text: "", jobId: "" }), 5000);
 | 
					 | 
				
			||||||
		},
 | 
					 | 
				
			||||||
	});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Fetch report mutation
 | 
					 | 
				
			||||||
	const fetchReportMutation = useMutation({
 | 
					 | 
				
			||||||
		mutationFn: () => adminHostsAPI.fetchReport(hostId).then((res) => res.data),
 | 
					 | 
				
			||||||
		onSuccess: (data) => {
 | 
					 | 
				
			||||||
			queryClient.invalidateQueries(["host", hostId]);
 | 
					 | 
				
			||||||
			queryClient.invalidateQueries(["hosts"]);
 | 
					 | 
				
			||||||
			// Show success message with job ID
 | 
					 | 
				
			||||||
			if (data?.jobId) {
 | 
					 | 
				
			||||||
				setReportMessage({
 | 
					 | 
				
			||||||
					text: "Report fetch queued successfully",
 | 
					 | 
				
			||||||
					jobId: data.jobId,
 | 
					 | 
				
			||||||
				});
 | 
					 | 
				
			||||||
				// Clear message after 5 seconds
 | 
					 | 
				
			||||||
				setTimeout(() => setReportMessage({ text: "", jobId: "" }), 5000);
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
		},
 | 
					 | 
				
			||||||
		onError: (error) => {
 | 
					 | 
				
			||||||
			setReportMessage({
 | 
					 | 
				
			||||||
				text: error.response?.data?.error || "Failed to fetch report",
 | 
					 | 
				
			||||||
				jobId: "",
 | 
					 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
			setTimeout(() => setReportMessage({ text: "", jobId: "" }), 5000);
 | 
					 | 
				
			||||||
		},
 | 
					 | 
				
			||||||
	});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	const updateFriendlyNameMutation = useMutation({
 | 
						const updateFriendlyNameMutation = useMutation({
 | 
				
			||||||
		mutationFn: (friendlyName) =>
 | 
							mutationFn: (friendlyName) =>
 | 
				
			||||||
			adminHostsAPI
 | 
								adminHostsAPI
 | 
				
			||||||
@@ -453,53 +399,20 @@ const HostDetail = () => {
 | 
				
			|||||||
					</div>
 | 
										</div>
 | 
				
			||||||
				</div>
 | 
									</div>
 | 
				
			||||||
				<div className="flex items-center gap-2">
 | 
									<div className="flex items-center gap-2">
 | 
				
			||||||
					<div>
 | 
					 | 
				
			||||||
						<button
 | 
					 | 
				
			||||||
							type="button"
 | 
					 | 
				
			||||||
							onClick={() => fetchReportMutation.mutate()}
 | 
					 | 
				
			||||||
							disabled={fetchReportMutation.isPending || !wsStatus?.connected}
 | 
					 | 
				
			||||||
							className="btn-outline flex items-center gap-2 text-sm"
 | 
					 | 
				
			||||||
							title={
 | 
					 | 
				
			||||||
								!wsStatus?.connected
 | 
					 | 
				
			||||||
									? "Agent is not connected"
 | 
					 | 
				
			||||||
									: "Fetch package data from agent"
 | 
					 | 
				
			||||||
							}
 | 
					 | 
				
			||||||
						>
 | 
					 | 
				
			||||||
							<Download
 | 
					 | 
				
			||||||
								className={`h-4 w-4 ${
 | 
					 | 
				
			||||||
									fetchReportMutation.isPending ? "animate-spin" : ""
 | 
					 | 
				
			||||||
								}`}
 | 
					 | 
				
			||||||
							/>
 | 
					 | 
				
			||||||
							Fetch Report
 | 
					 | 
				
			||||||
						</button>
 | 
					 | 
				
			||||||
						{reportMessage.text && (
 | 
					 | 
				
			||||||
							<p className="text-xs mt-1.5 text-secondary-600 dark:text-secondary-400">
 | 
					 | 
				
			||||||
								{reportMessage.text}
 | 
					 | 
				
			||||||
								{reportMessage.jobId && (
 | 
					 | 
				
			||||||
									<span className="ml-1 font-mono text-secondary-500">
 | 
					 | 
				
			||||||
										(Job #{reportMessage.jobId})
 | 
					 | 
				
			||||||
									</span>
 | 
					 | 
				
			||||||
								)}
 | 
					 | 
				
			||||||
							</p>
 | 
					 | 
				
			||||||
						)}
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
					<button
 | 
										<button
 | 
				
			||||||
						type="button"
 | 
											type="button"
 | 
				
			||||||
						onClick={() => setShowCredentialsModal(true)}
 | 
											onClick={() => setShowCredentialsModal(true)}
 | 
				
			||||||
						className={`btn-outline flex items-center text-sm ${
 | 
											className="btn-outline flex items-center gap-2 text-sm"
 | 
				
			||||||
							host?.machine_id ? "justify-center p-2" : "gap-2"
 | 
					 | 
				
			||||||
						}`}
 | 
					 | 
				
			||||||
						title="View credentials"
 | 
					 | 
				
			||||||
					>
 | 
										>
 | 
				
			||||||
						<Key className="h-4 w-4" />
 | 
											<Key className="h-4 w-4" />
 | 
				
			||||||
						{!host?.machine_id && <span>Deploy Agent</span>}
 | 
											Deploy Agent
 | 
				
			||||||
					</button>
 | 
										</button>
 | 
				
			||||||
					<button
 | 
										<button
 | 
				
			||||||
						type="button"
 | 
											type="button"
 | 
				
			||||||
						onClick={() => refetch()}
 | 
											onClick={() => refetch()}
 | 
				
			||||||
						disabled={isFetching}
 | 
											disabled={isFetching}
 | 
				
			||||||
						className="btn-outline flex items-center justify-center p-2 text-sm"
 | 
											className="btn-outline flex items-center justify-center p-2 text-sm"
 | 
				
			||||||
						title="Refresh dashboard"
 | 
											title="Refresh host data"
 | 
				
			||||||
					>
 | 
										>
 | 
				
			||||||
						<RefreshCw
 | 
											<RefreshCw
 | 
				
			||||||
							className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
 | 
												className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
 | 
				
			||||||
@@ -790,49 +703,6 @@ const HostDetail = () => {
 | 
				
			|||||||
											/>
 | 
																/>
 | 
				
			||||||
										</button>
 | 
															</button>
 | 
				
			||||||
									</div>
 | 
														</div>
 | 
				
			||||||
 | 
					 | 
				
			||||||
									<div>
 | 
					 | 
				
			||||||
										<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
 | 
					 | 
				
			||||||
											Force Agent Version Upgrade
 | 
					 | 
				
			||||||
										</p>
 | 
					 | 
				
			||||||
										<button
 | 
					 | 
				
			||||||
											type="button"
 | 
					 | 
				
			||||||
											onClick={() => forceAgentUpdateMutation.mutate()}
 | 
					 | 
				
			||||||
											disabled={
 | 
					 | 
				
			||||||
												forceAgentUpdateMutation.isPending ||
 | 
					 | 
				
			||||||
												!wsStatus?.connected
 | 
					 | 
				
			||||||
											}
 | 
					 | 
				
			||||||
											title={
 | 
					 | 
				
			||||||
												!wsStatus?.connected
 | 
					 | 
				
			||||||
													? "Agent is not connected"
 | 
					 | 
				
			||||||
													: "Force agent to update now"
 | 
					 | 
				
			||||||
											}
 | 
					 | 
				
			||||||
											className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-md hover:bg-primary-100 dark:hover:bg-primary-900/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
 | 
					 | 
				
			||||||
										>
 | 
					 | 
				
			||||||
											<RefreshCw
 | 
					 | 
				
			||||||
												className={`h-3 w-3 ${
 | 
					 | 
				
			||||||
													forceAgentUpdateMutation.isPending
 | 
					 | 
				
			||||||
														? "animate-spin"
 | 
					 | 
				
			||||||
														: ""
 | 
					 | 
				
			||||||
												}`}
 | 
					 | 
				
			||||||
											/>
 | 
					 | 
				
			||||||
											{forceAgentUpdateMutation.isPending
 | 
					 | 
				
			||||||
												? "Updating..."
 | 
					 | 
				
			||||||
												: wsStatus?.connected
 | 
					 | 
				
			||||||
													? "Update Now"
 | 
					 | 
				
			||||||
													: "Offline"}
 | 
					 | 
				
			||||||
										</button>
 | 
					 | 
				
			||||||
										{updateMessage.text && (
 | 
					 | 
				
			||||||
											<p className="text-xs mt-1.5 text-secondary-600 dark:text-secondary-400">
 | 
					 | 
				
			||||||
												{updateMessage.text}
 | 
					 | 
				
			||||||
												{updateMessage.jobId && (
 | 
					 | 
				
			||||||
													<span className="ml-1 font-mono text-secondary-500">
 | 
					 | 
				
			||||||
														(Job #{updateMessage.jobId})
 | 
					 | 
				
			||||||
													</span>
 | 
					 | 
				
			||||||
												)}
 | 
					 | 
				
			||||||
											</p>
 | 
					 | 
				
			||||||
										)}
 | 
					 | 
				
			||||||
									</div>
 | 
					 | 
				
			||||||
								</div>
 | 
													</div>
 | 
				
			||||||
							</div>
 | 
												</div>
 | 
				
			||||||
						)}
 | 
											)}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -470,18 +470,9 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// Delete Confirmation Modal
 | 
					// Delete Confirmation Modal
 | 
				
			||||||
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
 | 
					const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
 | 
				
			||||||
	// Fetch hosts for this group
 | 
					 | 
				
			||||||
	const { data: hostsData } = useQuery({
 | 
					 | 
				
			||||||
		queryKey: ["hostGroupHosts", group?.id],
 | 
					 | 
				
			||||||
		queryFn: () => hostGroupsAPI.getHosts(group.id).then((res) => res.data),
 | 
					 | 
				
			||||||
		enabled: !!group && group._count?.hosts > 0,
 | 
					 | 
				
			||||||
	});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	const hosts = hostsData || [];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	return (
 | 
						return (
 | 
				
			||||||
		<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
 | 
							<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
 | 
				
			||||||
			<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
 | 
								<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
 | 
				
			||||||
				<div className="flex items-center gap-3 mb-4">
 | 
									<div className="flex items-center gap-3 mb-4">
 | 
				
			||||||
					<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
 | 
										<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
 | 
				
			||||||
						<AlertTriangle className="h-5 w-5 text-danger-600" />
 | 
											<AlertTriangle className="h-5 w-5 text-danger-600" />
 | 
				
			||||||
@@ -503,30 +494,12 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
 | 
				
			|||||||
					</p>
 | 
										</p>
 | 
				
			||||||
					{group._count.hosts > 0 && (
 | 
										{group._count.hosts > 0 && (
 | 
				
			||||||
						<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
 | 
											<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
 | 
				
			||||||
							<p className="text-sm text-warning-800 mb-2">
 | 
												<p className="text-sm text-warning-800">
 | 
				
			||||||
								<strong>Warning:</strong> This group contains{" "}
 | 
													<strong>Warning:</strong> This group contains{" "}
 | 
				
			||||||
								{group._count.hosts} host
 | 
													{group._count.hosts} host
 | 
				
			||||||
								{group._count.hosts !== 1 ? "s" : ""}. You must move or remove
 | 
													{group._count.hosts !== 1 ? "s" : ""}. You must move or remove
 | 
				
			||||||
								these hosts before deleting the group.
 | 
													these hosts before deleting the group.
 | 
				
			||||||
							</p>
 | 
												</p>
 | 
				
			||||||
							{hosts.length > 0 && (
 | 
					 | 
				
			||||||
								<div className="mt-2">
 | 
					 | 
				
			||||||
									<p className="text-xs font-medium text-warning-900 mb-1">
 | 
					 | 
				
			||||||
										Hosts in this group:
 | 
					 | 
				
			||||||
									</p>
 | 
					 | 
				
			||||||
									<div className="max-h-32 overflow-y-auto bg-warning-100 rounded p-2">
 | 
					 | 
				
			||||||
										{hosts.map((host) => (
 | 
					 | 
				
			||||||
											<div
 | 
					 | 
				
			||||||
												key={host.id}
 | 
					 | 
				
			||||||
												className="text-xs text-warning-900 flex items-center gap-1"
 | 
					 | 
				
			||||||
											>
 | 
					 | 
				
			||||||
												<Server className="h-3 w-3" />
 | 
					 | 
				
			||||||
												{host.friendly_name || host.hostname}
 | 
					 | 
				
			||||||
											</div>
 | 
					 | 
				
			||||||
										))}
 | 
					 | 
				
			||||||
									</div>
 | 
					 | 
				
			||||||
								</div>
 | 
					 | 
				
			||||||
							)}
 | 
					 | 
				
			||||||
						</div>
 | 
											</div>
 | 
				
			||||||
					)}
 | 
										)}
 | 
				
			||||||
				</div>
 | 
									</div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -531,11 +531,12 @@ const Hosts = () => {
 | 
				
			|||||||
							"with new data:",
 | 
												"with new data:",
 | 
				
			||||||
							data.host,
 | 
												data.host,
 | 
				
			||||||
						);
 | 
											);
 | 
				
			||||||
						// Host already has host_group_memberships from backend
 | 
											// Ensure hostGroupId is set correctly
 | 
				
			||||||
						const updatedHost = {
 | 
											const updatedHost = {
 | 
				
			||||||
							...data.host,
 | 
												...data.host,
 | 
				
			||||||
 | 
												hostGroupId: data.host.host_groups?.id || null,
 | 
				
			||||||
						};
 | 
											};
 | 
				
			||||||
						console.log("Updated host in cache:", updatedHost);
 | 
											console.log("Updated host with hostGroupId:", updatedHost);
 | 
				
			||||||
						return updatedHost;
 | 
											return updatedHost;
 | 
				
			||||||
					}
 | 
										}
 | 
				
			||||||
					return host;
 | 
										return host;
 | 
				
			||||||
@@ -653,15 +654,11 @@ const Hosts = () => {
 | 
				
			|||||||
				host.os_type?.toLowerCase().includes(searchTerm.toLowerCase()) ||
 | 
									host.os_type?.toLowerCase().includes(searchTerm.toLowerCase()) ||
 | 
				
			||||||
				host.notes?.toLowerCase().includes(searchTerm.toLowerCase());
 | 
									host.notes?.toLowerCase().includes(searchTerm.toLowerCase());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			// Group filter - handle multiple groups per host
 | 
								// Group filter
 | 
				
			||||||
			const memberships = host.host_group_memberships || [];
 | 
					 | 
				
			||||||
			const matchesGroup =
 | 
								const matchesGroup =
 | 
				
			||||||
				groupFilter === "all" ||
 | 
									groupFilter === "all" ||
 | 
				
			||||||
				(groupFilter === "ungrouped" && memberships.length === 0) ||
 | 
									(groupFilter === "ungrouped" && !host.host_groups) ||
 | 
				
			||||||
				(groupFilter !== "ungrouped" &&
 | 
									(groupFilter !== "ungrouped" && host.host_groups?.id === groupFilter);
 | 
				
			||||||
					memberships.some(
 | 
					 | 
				
			||||||
						(membership) => membership.host_groups?.id === groupFilter,
 | 
					 | 
				
			||||||
					));
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
			// Status filter
 | 
								// Status filter
 | 
				
			||||||
			const matchesStatus =
 | 
								const matchesStatus =
 | 
				
			||||||
@@ -714,30 +711,10 @@ const Hosts = () => {
 | 
				
			|||||||
					aValue = a.ip?.toLowerCase() || "zzz_no_ip";
 | 
										aValue = a.ip?.toLowerCase() || "zzz_no_ip";
 | 
				
			||||||
					bValue = b.ip?.toLowerCase() || "zzz_no_ip";
 | 
										bValue = b.ip?.toLowerCase() || "zzz_no_ip";
 | 
				
			||||||
					break;
 | 
										break;
 | 
				
			||||||
				case "group": {
 | 
									case "group":
 | 
				
			||||||
					// Handle multiple groups per host - use first group alphabetically for sorting
 | 
										aValue = a.host_groups?.name || "zzz_ungrouped";
 | 
				
			||||||
					const aGroups = a.host_group_memberships || [];
 | 
										bValue = b.host_groups?.name || "zzz_ungrouped";
 | 
				
			||||||
					const bGroups = b.host_group_memberships || [];
 | 
					 | 
				
			||||||
					if (aGroups.length === 0) {
 | 
					 | 
				
			||||||
						aValue = "zzz_ungrouped";
 | 
					 | 
				
			||||||
					} else {
 | 
					 | 
				
			||||||
						const aGroupNames = aGroups
 | 
					 | 
				
			||||||
							.map((m) => m.host_groups?.name || "")
 | 
					 | 
				
			||||||
							.filter((name) => name)
 | 
					 | 
				
			||||||
							.sort();
 | 
					 | 
				
			||||||
						aValue = aGroupNames[0] || "zzz_ungrouped";
 | 
					 | 
				
			||||||
					}
 | 
					 | 
				
			||||||
					if (bGroups.length === 0) {
 | 
					 | 
				
			||||||
						bValue = "zzz_ungrouped";
 | 
					 | 
				
			||||||
					} else {
 | 
					 | 
				
			||||||
						const bGroupNames = bGroups
 | 
					 | 
				
			||||||
							.map((m) => m.host_groups?.name || "")
 | 
					 | 
				
			||||||
							.filter((name) => name)
 | 
					 | 
				
			||||||
							.sort();
 | 
					 | 
				
			||||||
						bValue = bGroupNames[0] || "zzz_ungrouped";
 | 
					 | 
				
			||||||
					}
 | 
					 | 
				
			||||||
					break;
 | 
										break;
 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
				case "os":
 | 
									case "os":
 | 
				
			||||||
					aValue = a.os_type?.toLowerCase() || "zzz_unknown";
 | 
										aValue = a.os_type?.toLowerCase() || "zzz_unknown";
 | 
				
			||||||
					bValue = b.os_type?.toLowerCase() || "zzz_unknown";
 | 
										bValue = b.os_type?.toLowerCase() || "zzz_unknown";
 | 
				
			||||||
@@ -810,46 +787,27 @@ const Hosts = () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		const groups = {};
 | 
							const groups = {};
 | 
				
			||||||
		filteredAndSortedHosts.forEach((host) => {
 | 
							filteredAndSortedHosts.forEach((host) => {
 | 
				
			||||||
			if (groupBy === "group") {
 | 
								let groupKey;
 | 
				
			||||||
				// Handle multiple groups per host
 | 
								switch (groupBy) {
 | 
				
			||||||
				const memberships = host.host_group_memberships || [];
 | 
									case "group":
 | 
				
			||||||
				if (memberships.length === 0) {
 | 
										groupKey = host.host_groups?.name || "Ungrouped";
 | 
				
			||||||
					// Host has no groups, add to "Ungrouped"
 | 
										break;
 | 
				
			||||||
					if (!groups.Ungrouped) {
 | 
									case "status":
 | 
				
			||||||
						groups.Ungrouped = [];
 | 
										groupKey =
 | 
				
			||||||
					}
 | 
											(host.effectiveStatus || host.status).charAt(0).toUpperCase() +
 | 
				
			||||||
					groups.Ungrouped.push(host);
 | 
											(host.effectiveStatus || host.status).slice(1);
 | 
				
			||||||
				} else {
 | 
										break;
 | 
				
			||||||
					// Host has one or more groups, add to each group
 | 
									case "os":
 | 
				
			||||||
					memberships.forEach((membership) => {
 | 
										groupKey = host.os_type || "Unknown";
 | 
				
			||||||
						const groupName = membership.host_groups?.name || "Unknown";
 | 
										break;
 | 
				
			||||||
						if (!groups[groupName]) {
 | 
									default:
 | 
				
			||||||
							groups[groupName] = [];
 | 
										groupKey = "All Hosts";
 | 
				
			||||||
						}
 | 
					 | 
				
			||||||
						groups[groupName].push(host);
 | 
					 | 
				
			||||||
					});
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
			} else {
 | 
					 | 
				
			||||||
				// Other grouping types (status, os, etc.)
 | 
					 | 
				
			||||||
				let groupKey;
 | 
					 | 
				
			||||||
				switch (groupBy) {
 | 
					 | 
				
			||||||
					case "status":
 | 
					 | 
				
			||||||
						groupKey =
 | 
					 | 
				
			||||||
							(host.effectiveStatus || host.status).charAt(0).toUpperCase() +
 | 
					 | 
				
			||||||
							(host.effectiveStatus || host.status).slice(1);
 | 
					 | 
				
			||||||
						break;
 | 
					 | 
				
			||||||
					case "os":
 | 
					 | 
				
			||||||
						groupKey = host.os_type || "Unknown";
 | 
					 | 
				
			||||||
						break;
 | 
					 | 
				
			||||||
					default:
 | 
					 | 
				
			||||||
						groupKey = "All Hosts";
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				if (!groups[groupKey]) {
 | 
					 | 
				
			||||||
					groups[groupKey] = [];
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
				groups[groupKey].push(host);
 | 
					 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								if (!groups[groupKey]) {
 | 
				
			||||||
 | 
									groups[groupKey] = [];
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								groups[groupKey].push(host);
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		return groups;
 | 
							return groups;
 | 
				
			||||||
@@ -1436,6 +1394,14 @@ const Hosts = () => {
 | 
				
			|||||||
									<AlertTriangle className="h-4 w-4" />
 | 
														<AlertTriangle className="h-4 w-4" />
 | 
				
			||||||
									Hide Stale
 | 
														Hide Stale
 | 
				
			||||||
								</button>
 | 
													</button>
 | 
				
			||||||
 | 
													<button
 | 
				
			||||||
 | 
														type="button"
 | 
				
			||||||
 | 
														onClick={() => setShowAddModal(true)}
 | 
				
			||||||
 | 
														className="btn-primary flex items-center gap-2"
 | 
				
			||||||
 | 
													>
 | 
				
			||||||
 | 
														<Plus className="h-4 w-4" />
 | 
				
			||||||
 | 
														Add Host
 | 
				
			||||||
 | 
													</button>
 | 
				
			||||||
							</div>
 | 
												</div>
 | 
				
			||||||
						</div>
 | 
											</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,6 +17,7 @@ import { useEffect, useId, useRef, useState } from "react";
 | 
				
			|||||||
import { FaReddit, FaYoutube } from "react-icons/fa";
 | 
					import { FaReddit, FaYoutube } from "react-icons/fa";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { useNavigate } from "react-router-dom";
 | 
					import { useNavigate } from "react-router-dom";
 | 
				
			||||||
 | 
					import trianglify from "trianglify";
 | 
				
			||||||
import DiscordIcon from "../components/DiscordIcon";
 | 
					import DiscordIcon from "../components/DiscordIcon";
 | 
				
			||||||
import { useAuth } from "../contexts/AuthContext";
 | 
					import { useAuth } from "../contexts/AuthContext";
 | 
				
			||||||
import { useColorTheme } from "../contexts/ColorThemeContext";
 | 
					import { useColorTheme } from "../contexts/ColorThemeContext";
 | 
				
			||||||
@@ -56,87 +57,27 @@ const Login = () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	const navigate = useNavigate();
 | 
						const navigate = useNavigate();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Generate clean radial gradient background with subtle triangular accents
 | 
						// Generate Trianglify background based on selected theme
 | 
				
			||||||
	useEffect(() => {
 | 
						useEffect(() => {
 | 
				
			||||||
		const generateBackground = () => {
 | 
							const generateBackground = () => {
 | 
				
			||||||
			if (!canvasRef.current || !themeConfig?.login) return;
 | 
								if (canvasRef.current && themeConfig?.login) {
 | 
				
			||||||
 | 
									// Get current date as seed for daily variation
 | 
				
			||||||
 | 
									const today = new Date();
 | 
				
			||||||
 | 
									const dateSeed = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			const canvas = canvasRef.current;
 | 
									// Generate pattern with selected theme configuration
 | 
				
			||||||
			canvas.width = canvas.offsetWidth;
 | 
									const pattern = trianglify({
 | 
				
			||||||
			canvas.height = canvas.offsetHeight;
 | 
										width: canvasRef.current.offsetWidth,
 | 
				
			||||||
			const ctx = canvas.getContext("2d");
 | 
										height: canvasRef.current.offsetHeight,
 | 
				
			||||||
 | 
										cellSize: themeConfig.login.cellSize,
 | 
				
			||||||
 | 
										variance: themeConfig.login.variance,
 | 
				
			||||||
 | 
										seed: dateSeed,
 | 
				
			||||||
 | 
										xColors: themeConfig.login.xColors,
 | 
				
			||||||
 | 
										yColors: themeConfig.login.yColors,
 | 
				
			||||||
 | 
									});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			// Get theme colors - pick first color from each palette
 | 
									// Render to canvas
 | 
				
			||||||
			const xColors = themeConfig.login.xColors || [
 | 
									pattern.toCanvas(canvasRef.current);
 | 
				
			||||||
				"#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();
 | 
					 | 
				
			||||||
					}
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -149,7 +90,7 @@ const Login = () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		window.addEventListener("resize", handleResize);
 | 
							window.addEventListener("resize", handleResize);
 | 
				
			||||||
		return () => window.removeEventListener("resize", handleResize);
 | 
							return () => window.removeEventListener("resize", handleResize);
 | 
				
			||||||
	}, [themeConfig]);
 | 
						}, [themeConfig]); // Regenerate when theme changes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Check if signup is enabled
 | 
						// Check if signup is enabled
 | 
				
			||||||
	useEffect(() => {
 | 
						useEffect(() => {
 | 
				
			||||||
@@ -248,8 +189,7 @@ const Login = () => {
 | 
				
			|||||||
			} catch (error) {
 | 
								} catch (error) {
 | 
				
			||||||
				console.error("Failed to fetch GitHub data:", error);
 | 
									console.error("Failed to fetch GitHub data:", error);
 | 
				
			||||||
				// Set fallback data if nothing cached
 | 
									// Set fallback data if nothing cached
 | 
				
			||||||
				const cachedRelease = localStorage.getItem("githubLatestRelease");
 | 
									if (!latestRelease) {
 | 
				
			||||||
				if (!cachedRelease) {
 | 
					 | 
				
			||||||
					setLatestRelease({
 | 
										setLatestRelease({
 | 
				
			||||||
						version: "v1.3.0",
 | 
											version: "v1.3.0",
 | 
				
			||||||
						name: "Latest Release",
 | 
											name: "Latest Release",
 | 
				
			||||||
@@ -261,7 +201,7 @@ const Login = () => {
 | 
				
			|||||||
		};
 | 
							};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		fetchGitHubData();
 | 
							fetchGitHubData();
 | 
				
			||||||
	}, []); // Run once on mount
 | 
						}, [latestRelease]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	const handleSubmit = async (e) => {
 | 
						const handleSubmit = async (e) => {
 | 
				
			||||||
		e.preventDefault();
 | 
							e.preventDefault();
 | 
				
			||||||
@@ -408,12 +348,7 @@ const Login = () => {
 | 
				
			|||||||
		setTfaData({
 | 
							setTfaData({
 | 
				
			||||||
			...tfaData,
 | 
								...tfaData,
 | 
				
			||||||
			[name]:
 | 
								[name]:
 | 
				
			||||||
				type === "checkbox"
 | 
									type === "checkbox" ? checked : value.replace(/\D/g, "").slice(0, 6),
 | 
				
			||||||
					? checked
 | 
					 | 
				
			||||||
					: value
 | 
					 | 
				
			||||||
							.toUpperCase()
 | 
					 | 
				
			||||||
							.replace(/[^A-Z0-9]/g, "")
 | 
					 | 
				
			||||||
							.slice(0, 6),
 | 
					 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
		// Clear error when user starts typing
 | 
							// Clear error when user starts typing
 | 
				
			||||||
		if (error) {
 | 
							if (error) {
 | 
				
			||||||
@@ -878,8 +813,7 @@ const Login = () => {
 | 
				
			|||||||
									Two-Factor Authentication
 | 
														Two-Factor Authentication
 | 
				
			||||||
								</h3>
 | 
													</h3>
 | 
				
			||||||
								<p className="mt-2 text-sm text-secondary-600 dark:text-secondary-400">
 | 
													<p className="mt-2 text-sm text-secondary-600 dark:text-secondary-400">
 | 
				
			||||||
									Enter the code from your authenticator app, or use a backup
 | 
														Enter the 6-digit code from your authenticator app
 | 
				
			||||||
									code
 | 
					 | 
				
			||||||
								</p>
 | 
													</p>
 | 
				
			||||||
							</div>
 | 
												</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -898,15 +832,11 @@ const Login = () => {
 | 
				
			|||||||
										required
 | 
															required
 | 
				
			||||||
										value={tfaData.token}
 | 
															value={tfaData.token}
 | 
				
			||||||
										onChange={handleTfaInputChange}
 | 
															onChange={handleTfaInputChange}
 | 
				
			||||||
										className="appearance-none rounded-md relative block w-full px-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm text-center text-lg font-mono tracking-widest uppercase"
 | 
															className="appearance-none rounded-md relative block w-full px-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm text-center text-lg font-mono tracking-widest"
 | 
				
			||||||
										placeholder="Enter code"
 | 
															placeholder="000000"
 | 
				
			||||||
										maxLength="6"
 | 
															maxLength="6"
 | 
				
			||||||
										pattern="[A-Z0-9]{6}"
 | 
					 | 
				
			||||||
									/>
 | 
														/>
 | 
				
			||||||
								</div>
 | 
													</div>
 | 
				
			||||||
								<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
									Enter a 6-digit TOTP code or a 6-character backup code
 | 
					 | 
				
			||||||
								</p>
 | 
					 | 
				
			||||||
							</div>
 | 
												</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							<div className="flex items-center">
 | 
												<div className="flex items-center">
 | 
				
			||||||
@@ -966,6 +896,12 @@ const Login = () => {
 | 
				
			|||||||
									Back to Login
 | 
														Back to Login
 | 
				
			||||||
								</button>
 | 
													</button>
 | 
				
			||||||
							</div>
 | 
												</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
												<div className="text-center">
 | 
				
			||||||
 | 
													<p className="text-sm text-secondary-600 dark:text-secondary-400">
 | 
				
			||||||
 | 
														Don't have access to your authenticator? Use a backup code.
 | 
				
			||||||
 | 
													</p>
 | 
				
			||||||
 | 
												</div>
 | 
				
			||||||
						</form>
 | 
											</form>
 | 
				
			||||||
					)}
 | 
										)}
 | 
				
			||||||
				</div>
 | 
									</div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -557,18 +557,9 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// Delete Confirmation Modal
 | 
					// Delete Confirmation Modal
 | 
				
			||||||
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
 | 
					const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
 | 
				
			||||||
	// Fetch hosts for this group
 | 
					 | 
				
			||||||
	const { data: hostsData } = useQuery({
 | 
					 | 
				
			||||||
		queryKey: ["hostGroupHosts", group?.id],
 | 
					 | 
				
			||||||
		queryFn: () => hostGroupsAPI.getHosts(group.id).then((res) => res.data),
 | 
					 | 
				
			||||||
		enabled: !!group && group._count?.hosts > 0,
 | 
					 | 
				
			||||||
	});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	const hosts = hostsData || [];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	return (
 | 
						return (
 | 
				
			||||||
		<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
 | 
							<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
 | 
				
			||||||
			<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
 | 
								<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
 | 
				
			||||||
				<div className="flex items-center gap-3 mb-4">
 | 
									<div className="flex items-center gap-3 mb-4">
 | 
				
			||||||
					<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
 | 
										<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
 | 
				
			||||||
						<AlertTriangle className="h-5 w-5 text-danger-600" />
 | 
											<AlertTriangle className="h-5 w-5 text-danger-600" />
 | 
				
			||||||
@@ -590,30 +581,12 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
 | 
				
			|||||||
					</p>
 | 
										</p>
 | 
				
			||||||
					{group._count.hosts > 0 && (
 | 
										{group._count.hosts > 0 && (
 | 
				
			||||||
						<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
 | 
											<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
 | 
				
			||||||
							<p className="text-sm text-warning-800 mb-2">
 | 
												<p className="text-sm text-warning-800">
 | 
				
			||||||
								<strong>Warning:</strong> This group contains{" "}
 | 
													<strong>Warning:</strong> This group contains{" "}
 | 
				
			||||||
								{group._count.hosts} host
 | 
													{group._count.hosts} host
 | 
				
			||||||
								{group._count.hosts !== 1 ? "s" : ""}. You must move or remove
 | 
													{group._count.hosts !== 1 ? "s" : ""}. You must move or remove
 | 
				
			||||||
								these hosts before deleting the group.
 | 
													these hosts before deleting the group.
 | 
				
			||||||
							</p>
 | 
												</p>
 | 
				
			||||||
							{hosts.length > 0 && (
 | 
					 | 
				
			||||||
								<div className="mt-2">
 | 
					 | 
				
			||||||
									<p className="text-xs font-medium text-warning-900 mb-1">
 | 
					 | 
				
			||||||
										Hosts in this group:
 | 
					 | 
				
			||||||
									</p>
 | 
					 | 
				
			||||||
									<div className="max-h-32 overflow-y-auto bg-warning-100 rounded p-2">
 | 
					 | 
				
			||||||
										{hosts.map((host) => (
 | 
					 | 
				
			||||||
											<div
 | 
					 | 
				
			||||||
												key={host.id}
 | 
					 | 
				
			||||||
												className="text-xs text-warning-900 flex items-center gap-1"
 | 
					 | 
				
			||||||
											>
 | 
					 | 
				
			||||||
												<Server className="h-3 w-3" />
 | 
					 | 
				
			||||||
												{host.friendly_name || host.hostname}
 | 
					 | 
				
			||||||
											</div>
 | 
					 | 
				
			||||||
										))}
 | 
					 | 
				
			||||||
									</div>
 | 
					 | 
				
			||||||
								</div>
 | 
					 | 
				
			||||||
							)}
 | 
					 | 
				
			||||||
						</div>
 | 
											</div>
 | 
				
			||||||
					)}
 | 
										)}
 | 
				
			||||||
				</div>
 | 
									</div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -539,7 +539,7 @@ const Packages = () => {
 | 
				
			|||||||
						<Package className="h-5 w-5 text-primary-600 mr-2" />
 | 
											<Package className="h-5 w-5 text-primary-600 mr-2" />
 | 
				
			||||||
						<div>
 | 
											<div>
 | 
				
			||||||
							<p className="text-sm text-secondary-500 dark:text-white">
 | 
												<p className="text-sm text-secondary-500 dark:text-white">
 | 
				
			||||||
								Packages
 | 
													Total Packages
 | 
				
			||||||
							</p>
 | 
												</p>
 | 
				
			||||||
							<p className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
												<p className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
				
			||||||
								{totalPackagesCount}
 | 
													{totalPackagesCount}
 | 
				
			||||||
@@ -553,7 +553,7 @@ const Packages = () => {
 | 
				
			|||||||
						<Package className="h-5 w-5 text-blue-600 mr-2" />
 | 
											<Package className="h-5 w-5 text-blue-600 mr-2" />
 | 
				
			||||||
						<div>
 | 
											<div>
 | 
				
			||||||
							<p className="text-sm text-secondary-500 dark:text-white">
 | 
												<p className="text-sm text-secondary-500 dark:text-white">
 | 
				
			||||||
								Installations
 | 
													Total Installations
 | 
				
			||||||
							</p>
 | 
												</p>
 | 
				
			||||||
							<p className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
												<p className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
				
			||||||
								{totalInstallationsCount}
 | 
													{totalInstallationsCount}
 | 
				
			||||||
@@ -562,72 +562,47 @@ const Packages = () => {
 | 
				
			|||||||
					</div>
 | 
										</div>
 | 
				
			||||||
				</div>
 | 
									</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
				<button
 | 
									<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
 | 
				
			||||||
					type="button"
 | 
					 | 
				
			||||||
					onClick={() => {
 | 
					 | 
				
			||||||
						setUpdateStatusFilter("needs-updates");
 | 
					 | 
				
			||||||
						setCategoryFilter("all");
 | 
					 | 
				
			||||||
						setHostFilter("all");
 | 
					 | 
				
			||||||
						setSearchTerm("");
 | 
					 | 
				
			||||||
					}}
 | 
					 | 
				
			||||||
					className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
 | 
					 | 
				
			||||||
					title="Click to filter packages that need updates"
 | 
					 | 
				
			||||||
				>
 | 
					 | 
				
			||||||
					<div className="flex items-center">
 | 
										<div className="flex items-center">
 | 
				
			||||||
						<Package className="h-5 w-5 text-warning-600 mr-2" />
 | 
											<Package className="h-5 w-5 text-warning-600 mr-2" />
 | 
				
			||||||
						<div>
 | 
											<div>
 | 
				
			||||||
							<p className="text-sm text-secondary-500 dark:text-white">
 | 
												<p className="text-sm text-secondary-500 dark:text-white">
 | 
				
			||||||
								Outdated Packages
 | 
													Total Outdated Packages
 | 
				
			||||||
							</p>
 | 
												</p>
 | 
				
			||||||
							<p className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
												<p className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
				
			||||||
								{outdatedPackagesCount}
 | 
													{outdatedPackagesCount}
 | 
				
			||||||
							</p>
 | 
												</p>
 | 
				
			||||||
						</div>
 | 
											</div>
 | 
				
			||||||
					</div>
 | 
										</div>
 | 
				
			||||||
				</button>
 | 
									</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
				<button
 | 
									<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
 | 
				
			||||||
					type="button"
 | 
					 | 
				
			||||||
					onClick={() => {
 | 
					 | 
				
			||||||
						setUpdateStatusFilter("security-updates");
 | 
					 | 
				
			||||||
						setCategoryFilter("all");
 | 
					 | 
				
			||||||
						setHostFilter("all");
 | 
					 | 
				
			||||||
						setSearchTerm("");
 | 
					 | 
				
			||||||
					}}
 | 
					 | 
				
			||||||
					className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
 | 
					 | 
				
			||||||
					title="Click to filter packages with security updates"
 | 
					 | 
				
			||||||
				>
 | 
					 | 
				
			||||||
					<div className="flex items-center">
 | 
					 | 
				
			||||||
						<Shield className="h-5 w-5 text-danger-600 mr-2" />
 | 
					 | 
				
			||||||
						<div>
 | 
					 | 
				
			||||||
							<p className="text-sm text-secondary-500 dark:text-white">
 | 
					 | 
				
			||||||
								Security Packages
 | 
					 | 
				
			||||||
							</p>
 | 
					 | 
				
			||||||
							<p className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
								{securityUpdatesCount}
 | 
					 | 
				
			||||||
							</p>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</button>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				<button
 | 
					 | 
				
			||||||
					type="button"
 | 
					 | 
				
			||||||
					onClick={() => navigate("/hosts?filter=needsUpdates")}
 | 
					 | 
				
			||||||
					className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
 | 
					 | 
				
			||||||
					title="Click to view hosts that need updates"
 | 
					 | 
				
			||||||
				>
 | 
					 | 
				
			||||||
					<div className="flex items-center">
 | 
										<div className="flex items-center">
 | 
				
			||||||
						<Server className="h-5 w-5 text-warning-600 mr-2" />
 | 
											<Server className="h-5 w-5 text-warning-600 mr-2" />
 | 
				
			||||||
						<div>
 | 
											<div>
 | 
				
			||||||
							<p className="text-sm text-secondary-500 dark:text-white">
 | 
												<p className="text-sm text-secondary-500 dark:text-white">
 | 
				
			||||||
								Outdated Hosts
 | 
													Hosts Pending Updates
 | 
				
			||||||
							</p>
 | 
												</p>
 | 
				
			||||||
							<p className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
												<p className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
				
			||||||
								{uniquePackageHostsCount}
 | 
													{uniquePackageHostsCount}
 | 
				
			||||||
							</p>
 | 
												</p>
 | 
				
			||||||
						</div>
 | 
											</div>
 | 
				
			||||||
					</div>
 | 
										</div>
 | 
				
			||||||
				</button>
 | 
									</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
 | 
				
			||||||
 | 
										<div className="flex items-center">
 | 
				
			||||||
 | 
											<Shield className="h-5 w-5 text-danger-600 mr-2" />
 | 
				
			||||||
 | 
											<div>
 | 
				
			||||||
 | 
												<p className="text-sm text-secondary-500 dark:text-white">
 | 
				
			||||||
 | 
													Security Updates Across All Hosts
 | 
				
			||||||
 | 
												</p>
 | 
				
			||||||
 | 
												<p className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
				
			||||||
 | 
													{securityUpdatesCount}
 | 
				
			||||||
 | 
												</p>
 | 
				
			||||||
 | 
											</div>
 | 
				
			||||||
 | 
										</div>
 | 
				
			||||||
 | 
									</div>
 | 
				
			||||||
			</div>
 | 
								</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			{/* Packages List */}
 | 
								{/* Packages List */}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -25,7 +25,6 @@ import {
 | 
				
			|||||||
import { useEffect, useId, useState } from "react";
 | 
					import { useEffect, useId, useState } from "react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { useAuth } from "../contexts/AuthContext";
 | 
					import { useAuth } from "../contexts/AuthContext";
 | 
				
			||||||
import { THEME_PRESETS, useColorTheme } from "../contexts/ColorThemeContext";
 | 
					 | 
				
			||||||
import { useTheme } from "../contexts/ThemeContext";
 | 
					import { useTheme } from "../contexts/ThemeContext";
 | 
				
			||||||
import { isCorsError, tfaAPI } from "../utils/api";
 | 
					import { isCorsError, tfaAPI } from "../utils/api";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -39,7 +38,6 @@ const Profile = () => {
 | 
				
			|||||||
	const confirmPasswordId = useId();
 | 
						const confirmPasswordId = useId();
 | 
				
			||||||
	const { user, updateProfile, changePassword } = useAuth();
 | 
						const { user, updateProfile, changePassword } = useAuth();
 | 
				
			||||||
	const { toggleTheme, isDark } = useTheme();
 | 
						const { toggleTheme, isDark } = useTheme();
 | 
				
			||||||
	const { colorTheme, setColorTheme } = useColorTheme();
 | 
					 | 
				
			||||||
	const [activeTab, setActiveTab] = useState("profile");
 | 
						const [activeTab, setActiveTab] = useState("profile");
 | 
				
			||||||
	const [isLoading, setIsLoading] = useState(false);
 | 
						const [isLoading, setIsLoading] = useState(false);
 | 
				
			||||||
	const [message, setMessage] = useState({ type: "", text: "" });
 | 
						const [message, setMessage] = useState({ type: "", text: "" });
 | 
				
			||||||
@@ -80,10 +78,8 @@ const Profile = () => {
 | 
				
			|||||||
		setIsLoading(true);
 | 
							setIsLoading(true);
 | 
				
			||||||
		setMessage({ type: "", text: "" });
 | 
							setMessage({ type: "", text: "" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		console.log("Submitting profile data:", profileData);
 | 
					 | 
				
			||||||
		try {
 | 
							try {
 | 
				
			||||||
			const result = await updateProfile(profileData);
 | 
								const result = await updateProfile(profileData);
 | 
				
			||||||
			console.log("Profile update result:", result);
 | 
					 | 
				
			||||||
			if (result.success) {
 | 
								if (result.success) {
 | 
				
			||||||
				setMessage({ type: "success", text: "Profile updated successfully!" });
 | 
									setMessage({ type: "success", text: "Profile updated successfully!" });
 | 
				
			||||||
			} else {
 | 
								} else {
 | 
				
			||||||
@@ -415,68 +411,6 @@ const Profile = () => {
 | 
				
			|||||||
										</button>
 | 
															</button>
 | 
				
			||||||
									</div>
 | 
														</div>
 | 
				
			||||||
								</div>
 | 
													</div>
 | 
				
			||||||
 | 
					 | 
				
			||||||
								{/* Color Theme Settings */}
 | 
					 | 
				
			||||||
								<div className="mt-6 pt-6 border-t border-secondary-200 dark:border-secondary-600">
 | 
					 | 
				
			||||||
									<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
 | 
					 | 
				
			||||||
										Color Theme
 | 
					 | 
				
			||||||
									</h4>
 | 
					 | 
				
			||||||
									<p className="text-xs text-secondary-500 dark:text-secondary-400 mb-4">
 | 
					 | 
				
			||||||
										Choose your preferred color scheme for the application
 | 
					 | 
				
			||||||
									</p>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
									<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
 | 
					 | 
				
			||||||
										{Object.entries(THEME_PRESETS).map(([themeKey, theme]) => {
 | 
					 | 
				
			||||||
											const isSelected = colorTheme === themeKey;
 | 
					 | 
				
			||||||
											const gradientColors = theme.login.xColors;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
											return (
 | 
					 | 
				
			||||||
												<button
 | 
					 | 
				
			||||||
													key={themeKey}
 | 
					 | 
				
			||||||
													type="button"
 | 
					 | 
				
			||||||
													onClick={() => setColorTheme(themeKey)}
 | 
					 | 
				
			||||||
													className={`relative p-4 rounded-lg border-2 transition-all ${
 | 
					 | 
				
			||||||
														isSelected
 | 
					 | 
				
			||||||
															? "border-primary-500 ring-2 ring-primary-200 dark:ring-primary-800"
 | 
					 | 
				
			||||||
															: "border-secondary-200 dark:border-secondary-600 hover:border-primary-300"
 | 
					 | 
				
			||||||
													} cursor-pointer`}
 | 
					 | 
				
			||||||
												>
 | 
					 | 
				
			||||||
													{/* Theme Preview */}
 | 
					 | 
				
			||||||
													<div
 | 
					 | 
				
			||||||
														className="h-20 rounded-md mb-3 overflow-hidden"
 | 
					 | 
				
			||||||
														style={{
 | 
					 | 
				
			||||||
															background: `linear-gradient(135deg, ${gradientColors.join(", ")})`,
 | 
					 | 
				
			||||||
														}}
 | 
					 | 
				
			||||||
													/>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
													{/* Theme Name */}
 | 
					 | 
				
			||||||
													<div className="text-sm font-medium text-secondary-900 dark:text-white mb-1">
 | 
					 | 
				
			||||||
														{theme.name}
 | 
					 | 
				
			||||||
													</div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
													{/* Selected Indicator */}
 | 
					 | 
				
			||||||
													{isSelected && (
 | 
					 | 
				
			||||||
														<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
 | 
					 | 
				
			||||||
															<svg
 | 
					 | 
				
			||||||
																className="w-4 h-4"
 | 
					 | 
				
			||||||
																fill="currentColor"
 | 
					 | 
				
			||||||
																viewBox="0 0 20 20"
 | 
					 | 
				
			||||||
																aria-label="Selected theme"
 | 
					 | 
				
			||||||
															>
 | 
					 | 
				
			||||||
																<title>Selected</title>
 | 
					 | 
				
			||||||
																<path
 | 
					 | 
				
			||||||
																	fillRule="evenodd"
 | 
					 | 
				
			||||||
																	d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
 | 
					 | 
				
			||||||
																	clipRule="evenodd"
 | 
					 | 
				
			||||||
																/>
 | 
					 | 
				
			||||||
															</svg>
 | 
					 | 
				
			||||||
														</div>
 | 
					 | 
				
			||||||
													)}
 | 
					 | 
				
			||||||
												</button>
 | 
					 | 
				
			||||||
											);
 | 
					 | 
				
			||||||
										})}
 | 
					 | 
				
			||||||
									</div>
 | 
					 | 
				
			||||||
								</div>
 | 
					 | 
				
			||||||
							</div>
 | 
												</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							<div className="flex justify-end">
 | 
												<div className="flex justify-end">
 | 
				
			||||||
@@ -630,7 +564,6 @@ const Profile = () => {
 | 
				
			|||||||
// TFA Tab Component
 | 
					// TFA Tab Component
 | 
				
			||||||
const TfaTab = () => {
 | 
					const TfaTab = () => {
 | 
				
			||||||
	const verificationTokenId = useId();
 | 
						const verificationTokenId = useId();
 | 
				
			||||||
	const disablePasswordId = useId();
 | 
					 | 
				
			||||||
	const [setupStep, setSetupStep] = useState("status"); // 'status', 'setup', 'verify', 'backup-codes'
 | 
						const [setupStep, setSetupStep] = useState("status"); // 'status', 'setup', 'verify', 'backup-codes'
 | 
				
			||||||
	const [verificationToken, setVerificationToken] = useState("");
 | 
						const [verificationToken, setVerificationToken] = useState("");
 | 
				
			||||||
	const [password, setPassword] = useState("");
 | 
						const [password, setPassword] = useState("");
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,483 +0,0 @@
 | 
				
			|||||||
import { useQuery } from "@tanstack/react-query";
 | 
					 | 
				
			||||||
import {
 | 
					 | 
				
			||||||
	AlertTriangle,
 | 
					 | 
				
			||||||
	ArrowLeft,
 | 
					 | 
				
			||||||
	CheckCircle,
 | 
					 | 
				
			||||||
	Container,
 | 
					 | 
				
			||||||
	Globe,
 | 
					 | 
				
			||||||
	Network,
 | 
					 | 
				
			||||||
	RefreshCw,
 | 
					 | 
				
			||||||
	Server,
 | 
					 | 
				
			||||||
	Tag,
 | 
					 | 
				
			||||||
	XCircle,
 | 
					 | 
				
			||||||
} from "lucide-react";
 | 
					 | 
				
			||||||
import { Link, useParams } from "react-router-dom";
 | 
					 | 
				
			||||||
import api, { formatRelativeTime } from "../../utils/api";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const NetworkDetail = () => {
 | 
					 | 
				
			||||||
	const { id } = useParams();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	const { data, isLoading, error } = useQuery({
 | 
					 | 
				
			||||||
		queryKey: ["docker", "network", id],
 | 
					 | 
				
			||||||
		queryFn: async () => {
 | 
					 | 
				
			||||||
			const response = await api.get(`/docker/networks/${id}`);
 | 
					 | 
				
			||||||
			return response.data;
 | 
					 | 
				
			||||||
		},
 | 
					 | 
				
			||||||
		refetchInterval: 30000,
 | 
					 | 
				
			||||||
	});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	const network = data?.network;
 | 
					 | 
				
			||||||
	const host = data?.host;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	if (isLoading) {
 | 
					 | 
				
			||||||
		return (
 | 
					 | 
				
			||||||
			<div className="flex items-center justify-center min-h-screen">
 | 
					 | 
				
			||||||
				<RefreshCw className="h-8 w-8 animate-spin text-secondary-400" />
 | 
					 | 
				
			||||||
			</div>
 | 
					 | 
				
			||||||
		);
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	if (error || !network) {
 | 
					 | 
				
			||||||
		return (
 | 
					 | 
				
			||||||
			<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
 | 
					 | 
				
			||||||
				<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
 | 
					 | 
				
			||||||
					<div className="flex">
 | 
					 | 
				
			||||||
						<AlertTriangle className="h-5 w-5 text-red-400" />
 | 
					 | 
				
			||||||
						<div className="ml-3">
 | 
					 | 
				
			||||||
							<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
 | 
					 | 
				
			||||||
								Network not found
 | 
					 | 
				
			||||||
							</h3>
 | 
					 | 
				
			||||||
							<p className="mt-2 text-sm text-red-700 dark:text-red-300">
 | 
					 | 
				
			||||||
								The network you're looking for doesn't exist or has been
 | 
					 | 
				
			||||||
								removed.
 | 
					 | 
				
			||||||
							</p>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
				<Link
 | 
					 | 
				
			||||||
					to="/docker"
 | 
					 | 
				
			||||||
					className="mt-4 inline-flex items-center text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
 | 
					 | 
				
			||||||
				>
 | 
					 | 
				
			||||||
					<ArrowLeft className="h-4 w-4 mr-2" />
 | 
					 | 
				
			||||||
					Back to Docker
 | 
					 | 
				
			||||||
				</Link>
 | 
					 | 
				
			||||||
			</div>
 | 
					 | 
				
			||||||
		);
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	const BooleanBadge = ({ value, trueLabel = "Yes", falseLabel = "No" }) => {
 | 
					 | 
				
			||||||
		return value ? (
 | 
					 | 
				
			||||||
			<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
 | 
					 | 
				
			||||||
				<CheckCircle className="h-3 w-3 mr-1" />
 | 
					 | 
				
			||||||
				{trueLabel}
 | 
					 | 
				
			||||||
			</span>
 | 
					 | 
				
			||||||
		) : (
 | 
					 | 
				
			||||||
			<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
 | 
					 | 
				
			||||||
				<XCircle className="h-3 w-3 mr-1" />
 | 
					 | 
				
			||||||
				{falseLabel}
 | 
					 | 
				
			||||||
			</span>
 | 
					 | 
				
			||||||
		);
 | 
					 | 
				
			||||||
	};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	return (
 | 
					 | 
				
			||||||
		<div className="space-y-6">
 | 
					 | 
				
			||||||
			{/* Header */}
 | 
					 | 
				
			||||||
			<div>
 | 
					 | 
				
			||||||
				<Link
 | 
					 | 
				
			||||||
					to="/docker"
 | 
					 | 
				
			||||||
					className="inline-flex items-center text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 mb-4"
 | 
					 | 
				
			||||||
				>
 | 
					 | 
				
			||||||
					<ArrowLeft className="h-4 w-4 mr-2" />
 | 
					 | 
				
			||||||
					Back to Docker
 | 
					 | 
				
			||||||
				</Link>
 | 
					 | 
				
			||||||
				<div className="flex items-center">
 | 
					 | 
				
			||||||
					<Network className="h-8 w-8 text-secondary-400 mr-3" />
 | 
					 | 
				
			||||||
					<div>
 | 
					 | 
				
			||||||
						<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
							{network.name}
 | 
					 | 
				
			||||||
						</h1>
 | 
					 | 
				
			||||||
						<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
 | 
					 | 
				
			||||||
							Network ID: {network.network_id.substring(0, 12)}
 | 
					 | 
				
			||||||
						</p>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
			</div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			{/* Overview Cards */}
 | 
					 | 
				
			||||||
			<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
 | 
					 | 
				
			||||||
				<div className="card p-4">
 | 
					 | 
				
			||||||
					<div className="flex items-center">
 | 
					 | 
				
			||||||
						<div className="flex-shrink-0">
 | 
					 | 
				
			||||||
							<Network className="h-5 w-5 text-blue-600 mr-2" />
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div className="w-0 flex-1">
 | 
					 | 
				
			||||||
							<p className="text-sm text-secondary-500 dark:text-white">
 | 
					 | 
				
			||||||
								Driver
 | 
					 | 
				
			||||||
							</p>
 | 
					 | 
				
			||||||
							<p className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
								{network.driver}
 | 
					 | 
				
			||||||
							</p>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				<div className="card p-4">
 | 
					 | 
				
			||||||
					<div className="flex items-center">
 | 
					 | 
				
			||||||
						<div className="flex-shrink-0">
 | 
					 | 
				
			||||||
							<Globe className="h-5 w-5 text-purple-600 mr-2" />
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div className="w-0 flex-1">
 | 
					 | 
				
			||||||
							<p className="text-sm text-secondary-500 dark:text-white">
 | 
					 | 
				
			||||||
								Scope
 | 
					 | 
				
			||||||
							</p>
 | 
					 | 
				
			||||||
							<p className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
								{network.scope}
 | 
					 | 
				
			||||||
							</p>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				<div className="card p-4">
 | 
					 | 
				
			||||||
					<div className="flex items-center">
 | 
					 | 
				
			||||||
						<div className="flex-shrink-0">
 | 
					 | 
				
			||||||
							<Container className="h-5 w-5 text-green-600 mr-2" />
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div className="w-0 flex-1">
 | 
					 | 
				
			||||||
							<p className="text-sm text-secondary-500 dark:text-white">
 | 
					 | 
				
			||||||
								Containers
 | 
					 | 
				
			||||||
							</p>
 | 
					 | 
				
			||||||
							<p className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
								{network.container_count || 0}
 | 
					 | 
				
			||||||
							</p>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				<div className="card p-4">
 | 
					 | 
				
			||||||
					<div className="flex items-center">
 | 
					 | 
				
			||||||
						<div className="flex-shrink-0">
 | 
					 | 
				
			||||||
							<RefreshCw className="h-5 w-5 text-secondary-400 mr-2" />
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div className="w-0 flex-1">
 | 
					 | 
				
			||||||
							<p className="text-sm text-secondary-500 dark:text-white">
 | 
					 | 
				
			||||||
								Last Checked
 | 
					 | 
				
			||||||
							</p>
 | 
					 | 
				
			||||||
							<p className="text-sm font-medium text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
								{formatRelativeTime(network.last_checked)}
 | 
					 | 
				
			||||||
							</p>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
			</div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			{/* Network Information Card */}
 | 
					 | 
				
			||||||
			<div className="card">
 | 
					 | 
				
			||||||
				<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
 | 
					 | 
				
			||||||
					<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
						Network Information
 | 
					 | 
				
			||||||
					</h3>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
				<div className="px-6 py-5">
 | 
					 | 
				
			||||||
					<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
 | 
					 | 
				
			||||||
						<div>
 | 
					 | 
				
			||||||
							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
								Network ID
 | 
					 | 
				
			||||||
							</dt>
 | 
					 | 
				
			||||||
							<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono break-all">
 | 
					 | 
				
			||||||
								{network.network_id}
 | 
					 | 
				
			||||||
							</dd>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div>
 | 
					 | 
				
			||||||
							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
								Name
 | 
					 | 
				
			||||||
							</dt>
 | 
					 | 
				
			||||||
							<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
								{network.name}
 | 
					 | 
				
			||||||
							</dd>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div>
 | 
					 | 
				
			||||||
							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
								Driver
 | 
					 | 
				
			||||||
							</dt>
 | 
					 | 
				
			||||||
							<dd className="mt-1">
 | 
					 | 
				
			||||||
								<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
 | 
					 | 
				
			||||||
									{network.driver}
 | 
					 | 
				
			||||||
								</span>
 | 
					 | 
				
			||||||
							</dd>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div>
 | 
					 | 
				
			||||||
							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
								Scope
 | 
					 | 
				
			||||||
							</dt>
 | 
					 | 
				
			||||||
							<dd className="mt-1">
 | 
					 | 
				
			||||||
								<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
 | 
					 | 
				
			||||||
									{network.scope}
 | 
					 | 
				
			||||||
								</span>
 | 
					 | 
				
			||||||
							</dd>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div>
 | 
					 | 
				
			||||||
							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
								Containers Attached
 | 
					 | 
				
			||||||
							</dt>
 | 
					 | 
				
			||||||
							<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
								{network.container_count || 0}
 | 
					 | 
				
			||||||
							</dd>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div>
 | 
					 | 
				
			||||||
							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
								IPv6 Enabled
 | 
					 | 
				
			||||||
							</dt>
 | 
					 | 
				
			||||||
							<dd className="mt-1">
 | 
					 | 
				
			||||||
								<BooleanBadge value={network.ipv6_enabled} />
 | 
					 | 
				
			||||||
							</dd>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div>
 | 
					 | 
				
			||||||
							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
								Internal
 | 
					 | 
				
			||||||
							</dt>
 | 
					 | 
				
			||||||
							<dd className="mt-1">
 | 
					 | 
				
			||||||
								<BooleanBadge value={network.internal} />
 | 
					 | 
				
			||||||
							</dd>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div>
 | 
					 | 
				
			||||||
							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
								Attachable
 | 
					 | 
				
			||||||
							</dt>
 | 
					 | 
				
			||||||
							<dd className="mt-1">
 | 
					 | 
				
			||||||
								<BooleanBadge value={network.attachable} />
 | 
					 | 
				
			||||||
							</dd>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div>
 | 
					 | 
				
			||||||
							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
								Ingress
 | 
					 | 
				
			||||||
							</dt>
 | 
					 | 
				
			||||||
							<dd className="mt-1">
 | 
					 | 
				
			||||||
								<BooleanBadge value={network.ingress} />
 | 
					 | 
				
			||||||
							</dd>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div>
 | 
					 | 
				
			||||||
							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
								Config Only
 | 
					 | 
				
			||||||
							</dt>
 | 
					 | 
				
			||||||
							<dd className="mt-1">
 | 
					 | 
				
			||||||
								<BooleanBadge value={network.config_only} />
 | 
					 | 
				
			||||||
							</dd>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						{network.created_at && (
 | 
					 | 
				
			||||||
							<div>
 | 
					 | 
				
			||||||
								<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
									Created
 | 
					 | 
				
			||||||
								</dt>
 | 
					 | 
				
			||||||
								<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
									{formatRelativeTime(network.created_at)}
 | 
					 | 
				
			||||||
								</dd>
 | 
					 | 
				
			||||||
							</div>
 | 
					 | 
				
			||||||
						)}
 | 
					 | 
				
			||||||
						<div>
 | 
					 | 
				
			||||||
							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
								Last Checked
 | 
					 | 
				
			||||||
							</dt>
 | 
					 | 
				
			||||||
							<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
								{formatRelativeTime(network.last_checked)}
 | 
					 | 
				
			||||||
							</dd>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
					</dl>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
			</div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			{/* IPAM Configuration */}
 | 
					 | 
				
			||||||
			{network.ipam && (
 | 
					 | 
				
			||||||
				<div className="card">
 | 
					 | 
				
			||||||
					<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
 | 
					 | 
				
			||||||
						<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
							IPAM Configuration
 | 
					 | 
				
			||||||
						</h3>
 | 
					 | 
				
			||||||
						<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
							IP Address Management settings
 | 
					 | 
				
			||||||
						</p>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
					<div className="px-6 py-5">
 | 
					 | 
				
			||||||
						{network.ipam.driver && (
 | 
					 | 
				
			||||||
							<div className="mb-4">
 | 
					 | 
				
			||||||
								<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-1">
 | 
					 | 
				
			||||||
									Driver
 | 
					 | 
				
			||||||
								</dt>
 | 
					 | 
				
			||||||
								<dd>
 | 
					 | 
				
			||||||
									<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
 | 
					 | 
				
			||||||
										{network.ipam.driver}
 | 
					 | 
				
			||||||
									</span>
 | 
					 | 
				
			||||||
								</dd>
 | 
					 | 
				
			||||||
							</div>
 | 
					 | 
				
			||||||
						)}
 | 
					 | 
				
			||||||
						{network.ipam.config && network.ipam.config.length > 0 && (
 | 
					 | 
				
			||||||
							<div>
 | 
					 | 
				
			||||||
								<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-3">
 | 
					 | 
				
			||||||
									Subnet Configuration
 | 
					 | 
				
			||||||
								</dt>
 | 
					 | 
				
			||||||
								<div className="space-y-4">
 | 
					 | 
				
			||||||
									{network.ipam.config.map((config, index) => (
 | 
					 | 
				
			||||||
										<div
 | 
					 | 
				
			||||||
											key={config.subnet || `config-${index}`}
 | 
					 | 
				
			||||||
											className="bg-secondary-50 dark:bg-secondary-900/50 rounded-lg p-4"
 | 
					 | 
				
			||||||
										>
 | 
					 | 
				
			||||||
											<dl className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
 | 
					 | 
				
			||||||
												{config.subnet && (
 | 
					 | 
				
			||||||
													<div>
 | 
					 | 
				
			||||||
														<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
															Subnet
 | 
					 | 
				
			||||||
														</dt>
 | 
					 | 
				
			||||||
														<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono">
 | 
					 | 
				
			||||||
															{config.subnet}
 | 
					 | 
				
			||||||
														</dd>
 | 
					 | 
				
			||||||
													</div>
 | 
					 | 
				
			||||||
												)}
 | 
					 | 
				
			||||||
												{config.gateway && (
 | 
					 | 
				
			||||||
													<div>
 | 
					 | 
				
			||||||
														<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
															Gateway
 | 
					 | 
				
			||||||
														</dt>
 | 
					 | 
				
			||||||
														<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono">
 | 
					 | 
				
			||||||
															{config.gateway}
 | 
					 | 
				
			||||||
														</dd>
 | 
					 | 
				
			||||||
													</div>
 | 
					 | 
				
			||||||
												)}
 | 
					 | 
				
			||||||
												{config.ip_range && (
 | 
					 | 
				
			||||||
													<div>
 | 
					 | 
				
			||||||
														<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
															IP Range
 | 
					 | 
				
			||||||
														</dt>
 | 
					 | 
				
			||||||
														<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono">
 | 
					 | 
				
			||||||
															{config.ip_range}
 | 
					 | 
				
			||||||
														</dd>
 | 
					 | 
				
			||||||
													</div>
 | 
					 | 
				
			||||||
												)}
 | 
					 | 
				
			||||||
												{config.aux_addresses &&
 | 
					 | 
				
			||||||
													Object.keys(config.aux_addresses).length > 0 && (
 | 
					 | 
				
			||||||
														<div className="sm:col-span-2">
 | 
					 | 
				
			||||||
															<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400 mb-2">
 | 
					 | 
				
			||||||
																Auxiliary Addresses
 | 
					 | 
				
			||||||
															</dt>
 | 
					 | 
				
			||||||
															<dd className="space-y-1">
 | 
					 | 
				
			||||||
																{Object.entries(config.aux_addresses).map(
 | 
					 | 
				
			||||||
																	([key, value]) => (
 | 
					 | 
				
			||||||
																		<div
 | 
					 | 
				
			||||||
																			key={key}
 | 
					 | 
				
			||||||
																			className="flex items-center text-sm"
 | 
					 | 
				
			||||||
																		>
 | 
					 | 
				
			||||||
																			<span className="text-secondary-500 dark:text-secondary-400 min-w-[120px]">
 | 
					 | 
				
			||||||
																				{key}:
 | 
					 | 
				
			||||||
																			</span>
 | 
					 | 
				
			||||||
																			<span className="text-secondary-900 dark:text-white font-mono">
 | 
					 | 
				
			||||||
																				{value}
 | 
					 | 
				
			||||||
																			</span>
 | 
					 | 
				
			||||||
																		</div>
 | 
					 | 
				
			||||||
																	),
 | 
					 | 
				
			||||||
																)}
 | 
					 | 
				
			||||||
															</dd>
 | 
					 | 
				
			||||||
														</div>
 | 
					 | 
				
			||||||
													)}
 | 
					 | 
				
			||||||
											</dl>
 | 
					 | 
				
			||||||
										</div>
 | 
					 | 
				
			||||||
									))}
 | 
					 | 
				
			||||||
								</div>
 | 
					 | 
				
			||||||
							</div>
 | 
					 | 
				
			||||||
						)}
 | 
					 | 
				
			||||||
						{network.ipam.options &&
 | 
					 | 
				
			||||||
							Object.keys(network.ipam.options).length > 0 && (
 | 
					 | 
				
			||||||
								<div className="mt-4">
 | 
					 | 
				
			||||||
									<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-2">
 | 
					 | 
				
			||||||
										IPAM Options
 | 
					 | 
				
			||||||
									</dt>
 | 
					 | 
				
			||||||
									<dd className="space-y-1">
 | 
					 | 
				
			||||||
										{Object.entries(network.ipam.options).map(
 | 
					 | 
				
			||||||
											([key, value]) => (
 | 
					 | 
				
			||||||
												<div
 | 
					 | 
				
			||||||
													key={key}
 | 
					 | 
				
			||||||
													className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0"
 | 
					 | 
				
			||||||
												>
 | 
					 | 
				
			||||||
													<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]">
 | 
					 | 
				
			||||||
														{key}
 | 
					 | 
				
			||||||
													</span>
 | 
					 | 
				
			||||||
													<span className="text-sm text-secondary-900 dark:text-white break-all">
 | 
					 | 
				
			||||||
														{value}
 | 
					 | 
				
			||||||
													</span>
 | 
					 | 
				
			||||||
												</div>
 | 
					 | 
				
			||||||
											),
 | 
					 | 
				
			||||||
										)}
 | 
					 | 
				
			||||||
									</dd>
 | 
					 | 
				
			||||||
								</div>
 | 
					 | 
				
			||||||
							)}
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
			)}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			{/* Host Information */}
 | 
					 | 
				
			||||||
			{host && (
 | 
					 | 
				
			||||||
				<div className="card">
 | 
					 | 
				
			||||||
					<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
 | 
					 | 
				
			||||||
						<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center">
 | 
					 | 
				
			||||||
							<Server className="h-5 w-5 mr-2" />
 | 
					 | 
				
			||||||
							Host Information
 | 
					 | 
				
			||||||
						</h3>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
					<div className="px-6 py-5">
 | 
					 | 
				
			||||||
						<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
 | 
					 | 
				
			||||||
							<div>
 | 
					 | 
				
			||||||
								<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
									Hostname
 | 
					 | 
				
			||||||
								</dt>
 | 
					 | 
				
			||||||
								<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
									<Link
 | 
					 | 
				
			||||||
										to={`/hosts/${host.id}`}
 | 
					 | 
				
			||||||
										className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
 | 
					 | 
				
			||||||
									>
 | 
					 | 
				
			||||||
										{host.hostname}
 | 
					 | 
				
			||||||
									</Link>
 | 
					 | 
				
			||||||
								</dd>
 | 
					 | 
				
			||||||
							</div>
 | 
					 | 
				
			||||||
							<div>
 | 
					 | 
				
			||||||
								<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
									Operating System
 | 
					 | 
				
			||||||
								</dt>
 | 
					 | 
				
			||||||
								<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
									{host.os_name} {host.os_version}
 | 
					 | 
				
			||||||
								</dd>
 | 
					 | 
				
			||||||
							</div>
 | 
					 | 
				
			||||||
						</dl>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
			)}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			{/* Labels */}
 | 
					 | 
				
			||||||
			{network.labels && Object.keys(network.labels).length > 0 && (
 | 
					 | 
				
			||||||
				<div className="card">
 | 
					 | 
				
			||||||
					<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
 | 
					 | 
				
			||||||
						<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center">
 | 
					 | 
				
			||||||
							<Tag className="h-5 w-5 mr-2" />
 | 
					 | 
				
			||||||
							Labels
 | 
					 | 
				
			||||||
						</h3>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
					<div className="px-6 py-5">
 | 
					 | 
				
			||||||
						<div className="space-y-2">
 | 
					 | 
				
			||||||
							{Object.entries(network.labels).map(([key, value]) => (
 | 
					 | 
				
			||||||
								<div
 | 
					 | 
				
			||||||
									key={key}
 | 
					 | 
				
			||||||
									className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0"
 | 
					 | 
				
			||||||
								>
 | 
					 | 
				
			||||||
									<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]">
 | 
					 | 
				
			||||||
										{key}
 | 
					 | 
				
			||||||
									</span>
 | 
					 | 
				
			||||||
									<span className="text-sm text-secondary-900 dark:text-white break-all">
 | 
					 | 
				
			||||||
										{value}
 | 
					 | 
				
			||||||
									</span>
 | 
					 | 
				
			||||||
								</div>
 | 
					 | 
				
			||||||
							))}
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
			)}
 | 
					 | 
				
			||||||
		</div>
 | 
					 | 
				
			||||||
	);
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export default NetworkDetail;
 | 
					 | 
				
			||||||
@@ -1,359 +0,0 @@
 | 
				
			|||||||
import { useQuery } from "@tanstack/react-query";
 | 
					 | 
				
			||||||
import {
 | 
					 | 
				
			||||||
	AlertTriangle,
 | 
					 | 
				
			||||||
	ArrowLeft,
 | 
					 | 
				
			||||||
	Database,
 | 
					 | 
				
			||||||
	HardDrive,
 | 
					 | 
				
			||||||
	RefreshCw,
 | 
					 | 
				
			||||||
	Server,
 | 
					 | 
				
			||||||
	Tag,
 | 
					 | 
				
			||||||
} from "lucide-react";
 | 
					 | 
				
			||||||
import { Link, useParams } from "react-router-dom";
 | 
					 | 
				
			||||||
import api, { formatRelativeTime } from "../../utils/api";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const VolumeDetail = () => {
 | 
					 | 
				
			||||||
	const { id } = useParams();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	const { data, isLoading, error } = useQuery({
 | 
					 | 
				
			||||||
		queryKey: ["docker", "volume", id],
 | 
					 | 
				
			||||||
		queryFn: async () => {
 | 
					 | 
				
			||||||
			const response = await api.get(`/docker/volumes/${id}`);
 | 
					 | 
				
			||||||
			return response.data;
 | 
					 | 
				
			||||||
		},
 | 
					 | 
				
			||||||
		refetchInterval: 30000,
 | 
					 | 
				
			||||||
	});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	const volume = data?.volume;
 | 
					 | 
				
			||||||
	const host = data?.host;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	if (isLoading) {
 | 
					 | 
				
			||||||
		return (
 | 
					 | 
				
			||||||
			<div className="flex items-center justify-center min-h-screen">
 | 
					 | 
				
			||||||
				<RefreshCw className="h-8 w-8 animate-spin text-secondary-400" />
 | 
					 | 
				
			||||||
			</div>
 | 
					 | 
				
			||||||
		);
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	if (error || !volume) {
 | 
					 | 
				
			||||||
		return (
 | 
					 | 
				
			||||||
			<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
 | 
					 | 
				
			||||||
				<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
 | 
					 | 
				
			||||||
					<div className="flex">
 | 
					 | 
				
			||||||
						<AlertTriangle className="h-5 w-5 text-red-400" />
 | 
					 | 
				
			||||||
						<div className="ml-3">
 | 
					 | 
				
			||||||
							<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
 | 
					 | 
				
			||||||
								Volume not found
 | 
					 | 
				
			||||||
							</h3>
 | 
					 | 
				
			||||||
							<p className="mt-2 text-sm text-red-700 dark:text-red-300">
 | 
					 | 
				
			||||||
								The volume you're looking for doesn't exist or has been removed.
 | 
					 | 
				
			||||||
							</p>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
				<Link
 | 
					 | 
				
			||||||
					to="/docker"
 | 
					 | 
				
			||||||
					className="mt-4 inline-flex items-center text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
 | 
					 | 
				
			||||||
				>
 | 
					 | 
				
			||||||
					<ArrowLeft className="h-4 w-4 mr-2" />
 | 
					 | 
				
			||||||
					Back to Docker
 | 
					 | 
				
			||||||
				</Link>
 | 
					 | 
				
			||||||
			</div>
 | 
					 | 
				
			||||||
		);
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	const formatBytes = (bytes) => {
 | 
					 | 
				
			||||||
		if (bytes === null || bytes === undefined) return "N/A";
 | 
					 | 
				
			||||||
		const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
 | 
					 | 
				
			||||||
		if (bytes === 0) return "0 Bytes";
 | 
					 | 
				
			||||||
		const i = Math.floor(Math.log(bytes) / Math.log(1024));
 | 
					 | 
				
			||||||
		return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
 | 
					 | 
				
			||||||
	};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	return (
 | 
					 | 
				
			||||||
		<div className="space-y-6">
 | 
					 | 
				
			||||||
			{/* Header */}
 | 
					 | 
				
			||||||
			<div>
 | 
					 | 
				
			||||||
				<Link
 | 
					 | 
				
			||||||
					to="/docker"
 | 
					 | 
				
			||||||
					className="inline-flex items-center text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 mb-4"
 | 
					 | 
				
			||||||
				>
 | 
					 | 
				
			||||||
					<ArrowLeft className="h-4 w-4 mr-2" />
 | 
					 | 
				
			||||||
					Back to Docker
 | 
					 | 
				
			||||||
				</Link>
 | 
					 | 
				
			||||||
				<div className="flex items-center">
 | 
					 | 
				
			||||||
					<HardDrive className="h-8 w-8 text-secondary-400 mr-3" />
 | 
					 | 
				
			||||||
					<div>
 | 
					 | 
				
			||||||
						<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
							{volume.name}
 | 
					 | 
				
			||||||
						</h1>
 | 
					 | 
				
			||||||
						<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
 | 
					 | 
				
			||||||
							Volume ID: {volume.volume_id}
 | 
					 | 
				
			||||||
						</p>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
			</div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			{/* Overview Cards */}
 | 
					 | 
				
			||||||
			<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
 | 
					 | 
				
			||||||
				<div className="card p-4">
 | 
					 | 
				
			||||||
					<div className="flex items-center">
 | 
					 | 
				
			||||||
						<div className="flex-shrink-0">
 | 
					 | 
				
			||||||
							<HardDrive className="h-5 w-5 text-blue-600 mr-2" />
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div className="w-0 flex-1">
 | 
					 | 
				
			||||||
							<p className="text-sm text-secondary-500 dark:text-white">
 | 
					 | 
				
			||||||
								Driver
 | 
					 | 
				
			||||||
							</p>
 | 
					 | 
				
			||||||
							<p className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
								{volume.driver}
 | 
					 | 
				
			||||||
							</p>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				<div className="card p-4">
 | 
					 | 
				
			||||||
					<div className="flex items-center">
 | 
					 | 
				
			||||||
						<div className="flex-shrink-0">
 | 
					 | 
				
			||||||
							<Database className="h-5 w-5 text-purple-600 mr-2" />
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div className="w-0 flex-1">
 | 
					 | 
				
			||||||
							<p className="text-sm text-secondary-500 dark:text-white">Size</p>
 | 
					 | 
				
			||||||
							<p className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
								{formatBytes(volume.size_bytes)}
 | 
					 | 
				
			||||||
							</p>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				<div className="card p-4">
 | 
					 | 
				
			||||||
					<div className="flex items-center">
 | 
					 | 
				
			||||||
						<div className="flex-shrink-0">
 | 
					 | 
				
			||||||
							<Server className="h-5 w-5 text-green-600 mr-2" />
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div className="w-0 flex-1">
 | 
					 | 
				
			||||||
							<p className="text-sm text-secondary-500 dark:text-white">
 | 
					 | 
				
			||||||
								Containers
 | 
					 | 
				
			||||||
							</p>
 | 
					 | 
				
			||||||
							<p className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
								{volume.ref_count || 0}
 | 
					 | 
				
			||||||
							</p>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				<div className="card p-4">
 | 
					 | 
				
			||||||
					<div className="flex items-center">
 | 
					 | 
				
			||||||
						<div className="flex-shrink-0">
 | 
					 | 
				
			||||||
							<RefreshCw className="h-5 w-5 text-secondary-400 mr-2" />
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div className="w-0 flex-1">
 | 
					 | 
				
			||||||
							<p className="text-sm text-secondary-500 dark:text-white">
 | 
					 | 
				
			||||||
								Last Checked
 | 
					 | 
				
			||||||
							</p>
 | 
					 | 
				
			||||||
							<p className="text-sm font-medium text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
								{formatRelativeTime(volume.last_checked)}
 | 
					 | 
				
			||||||
							</p>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
			</div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			{/* Volume Information Card */}
 | 
					 | 
				
			||||||
			<div className="card">
 | 
					 | 
				
			||||||
				<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
 | 
					 | 
				
			||||||
					<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
						Volume Information
 | 
					 | 
				
			||||||
					</h3>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
				<div className="px-6 py-5">
 | 
					 | 
				
			||||||
					<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
 | 
					 | 
				
			||||||
						<div>
 | 
					 | 
				
			||||||
							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
								Volume ID
 | 
					 | 
				
			||||||
							</dt>
 | 
					 | 
				
			||||||
							<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono">
 | 
					 | 
				
			||||||
								{volume.volume_id}
 | 
					 | 
				
			||||||
							</dd>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div>
 | 
					 | 
				
			||||||
							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
								Name
 | 
					 | 
				
			||||||
							</dt>
 | 
					 | 
				
			||||||
							<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
								{volume.name}
 | 
					 | 
				
			||||||
							</dd>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div>
 | 
					 | 
				
			||||||
							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
								Driver
 | 
					 | 
				
			||||||
							</dt>
 | 
					 | 
				
			||||||
							<dd className="mt-1">
 | 
					 | 
				
			||||||
								<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
 | 
					 | 
				
			||||||
									{volume.driver}
 | 
					 | 
				
			||||||
								</span>
 | 
					 | 
				
			||||||
							</dd>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div>
 | 
					 | 
				
			||||||
							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
								Scope
 | 
					 | 
				
			||||||
							</dt>
 | 
					 | 
				
			||||||
							<dd className="mt-1">
 | 
					 | 
				
			||||||
								<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
 | 
					 | 
				
			||||||
									{volume.scope}
 | 
					 | 
				
			||||||
								</span>
 | 
					 | 
				
			||||||
							</dd>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div>
 | 
					 | 
				
			||||||
							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
								Size
 | 
					 | 
				
			||||||
							</dt>
 | 
					 | 
				
			||||||
							<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
								{formatBytes(volume.size_bytes)}
 | 
					 | 
				
			||||||
							</dd>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div>
 | 
					 | 
				
			||||||
							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
								Containers Using
 | 
					 | 
				
			||||||
							</dt>
 | 
					 | 
				
			||||||
							<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
								{volume.ref_count || 0}
 | 
					 | 
				
			||||||
							</dd>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						{volume.mountpoint && (
 | 
					 | 
				
			||||||
							<div className="sm:col-span-2">
 | 
					 | 
				
			||||||
								<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
									Mount Point
 | 
					 | 
				
			||||||
								</dt>
 | 
					 | 
				
			||||||
								<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono break-all">
 | 
					 | 
				
			||||||
									{volume.mountpoint}
 | 
					 | 
				
			||||||
								</dd>
 | 
					 | 
				
			||||||
							</div>
 | 
					 | 
				
			||||||
						)}
 | 
					 | 
				
			||||||
						{volume.renderer && (
 | 
					 | 
				
			||||||
							<div>
 | 
					 | 
				
			||||||
								<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
									Renderer
 | 
					 | 
				
			||||||
								</dt>
 | 
					 | 
				
			||||||
								<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
									{volume.renderer}
 | 
					 | 
				
			||||||
								</dd>
 | 
					 | 
				
			||||||
							</div>
 | 
					 | 
				
			||||||
						)}
 | 
					 | 
				
			||||||
						<div>
 | 
					 | 
				
			||||||
							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
								Created
 | 
					 | 
				
			||||||
							</dt>
 | 
					 | 
				
			||||||
							<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
								{formatRelativeTime(volume.created_at)}
 | 
					 | 
				
			||||||
							</dd>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
						<div>
 | 
					 | 
				
			||||||
							<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
								Last Checked
 | 
					 | 
				
			||||||
							</dt>
 | 
					 | 
				
			||||||
							<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
								{formatRelativeTime(volume.last_checked)}
 | 
					 | 
				
			||||||
							</dd>
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
					</dl>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
			</div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			{/* Host Information */}
 | 
					 | 
				
			||||||
			{host && (
 | 
					 | 
				
			||||||
				<div className="card">
 | 
					 | 
				
			||||||
					<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
 | 
					 | 
				
			||||||
						<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center">
 | 
					 | 
				
			||||||
							<Server className="h-5 w-5 mr-2" />
 | 
					 | 
				
			||||||
							Host Information
 | 
					 | 
				
			||||||
						</h3>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
					<div className="px-6 py-5">
 | 
					 | 
				
			||||||
						<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
 | 
					 | 
				
			||||||
							<div>
 | 
					 | 
				
			||||||
								<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
									Hostname
 | 
					 | 
				
			||||||
								</dt>
 | 
					 | 
				
			||||||
								<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
									<Link
 | 
					 | 
				
			||||||
										to={`/hosts/${host.id}`}
 | 
					 | 
				
			||||||
										className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
 | 
					 | 
				
			||||||
									>
 | 
					 | 
				
			||||||
										{host.hostname}
 | 
					 | 
				
			||||||
									</Link>
 | 
					 | 
				
			||||||
								</dd>
 | 
					 | 
				
			||||||
							</div>
 | 
					 | 
				
			||||||
							<div>
 | 
					 | 
				
			||||||
								<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
 | 
					 | 
				
			||||||
									Operating System
 | 
					 | 
				
			||||||
								</dt>
 | 
					 | 
				
			||||||
								<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
									{host.os_name} {host.os_version}
 | 
					 | 
				
			||||||
								</dd>
 | 
					 | 
				
			||||||
							</div>
 | 
					 | 
				
			||||||
						</dl>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
			)}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			{/* Labels */}
 | 
					 | 
				
			||||||
			{volume.labels && Object.keys(volume.labels).length > 0 && (
 | 
					 | 
				
			||||||
				<div className="card">
 | 
					 | 
				
			||||||
					<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
 | 
					 | 
				
			||||||
						<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center">
 | 
					 | 
				
			||||||
							<Tag className="h-5 w-5 mr-2" />
 | 
					 | 
				
			||||||
							Labels
 | 
					 | 
				
			||||||
						</h3>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
					<div className="px-6 py-5">
 | 
					 | 
				
			||||||
						<div className="space-y-2">
 | 
					 | 
				
			||||||
							{Object.entries(volume.labels).map(([key, value]) => (
 | 
					 | 
				
			||||||
								<div
 | 
					 | 
				
			||||||
									key={key}
 | 
					 | 
				
			||||||
									className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0"
 | 
					 | 
				
			||||||
								>
 | 
					 | 
				
			||||||
									<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]">
 | 
					 | 
				
			||||||
										{key}
 | 
					 | 
				
			||||||
									</span>
 | 
					 | 
				
			||||||
									<span className="text-sm text-secondary-900 dark:text-white break-all">
 | 
					 | 
				
			||||||
										{value}
 | 
					 | 
				
			||||||
									</span>
 | 
					 | 
				
			||||||
								</div>
 | 
					 | 
				
			||||||
							))}
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
			)}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			{/* Options */}
 | 
					 | 
				
			||||||
			{volume.options && Object.keys(volume.options).length > 0 && (
 | 
					 | 
				
			||||||
				<div className="card">
 | 
					 | 
				
			||||||
					<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
 | 
					 | 
				
			||||||
						<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white">
 | 
					 | 
				
			||||||
							Volume Options
 | 
					 | 
				
			||||||
						</h3>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
					<div className="px-6 py-5">
 | 
					 | 
				
			||||||
						<div className="space-y-2">
 | 
					 | 
				
			||||||
							{Object.entries(volume.options).map(([key, value]) => (
 | 
					 | 
				
			||||||
								<div
 | 
					 | 
				
			||||||
									key={key}
 | 
					 | 
				
			||||||
									className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0"
 | 
					 | 
				
			||||||
								>
 | 
					 | 
				
			||||||
									<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]">
 | 
					 | 
				
			||||||
										{key}
 | 
					 | 
				
			||||||
									</span>
 | 
					 | 
				
			||||||
									<span className="text-sm text-secondary-900 dark:text-white break-all">
 | 
					 | 
				
			||||||
										{value}
 | 
					 | 
				
			||||||
									</span>
 | 
					 | 
				
			||||||
								</div>
 | 
					 | 
				
			||||||
							))}
 | 
					 | 
				
			||||||
						</div>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
			)}
 | 
					 | 
				
			||||||
		</div>
 | 
					 | 
				
			||||||
	);
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export default VolumeDetail;
 | 
					 | 
				
			||||||
@@ -746,126 +746,239 @@ const Integrations = () => {
 | 
				
			|||||||
									</div>
 | 
														</div>
 | 
				
			||||||
									<div>
 | 
														<div>
 | 
				
			||||||
										<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
 | 
															<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
 | 
				
			||||||
											Docker Inventory Collection
 | 
																Docker Container Monitoring
 | 
				
			||||||
										</h3>
 | 
															</h3>
 | 
				
			||||||
										<p className="text-sm text-secondary-600 dark:text-secondary-400">
 | 
															<p className="text-sm text-secondary-600 dark:text-secondary-400">
 | 
				
			||||||
											Docker monitoring is now built into the PatchMon Go agent
 | 
																Monitor Docker containers and images for available updates
 | 
				
			||||||
										</p>
 | 
															</p>
 | 
				
			||||||
									</div>
 | 
														</div>
 | 
				
			||||||
								</div>
 | 
													</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
								{/* Info Message */}
 | 
													{/* Installation Instructions */}
 | 
				
			||||||
								<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6">
 | 
													<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6">
 | 
				
			||||||
									<div className="flex items-start gap-3">
 | 
														<h3 className="text-lg font-semibold text-primary-900 dark:text-primary-200 mb-4">
 | 
				
			||||||
										<CheckCircle className="h-5 w-5 text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5" />
 | 
															Agent Installation
 | 
				
			||||||
										<div>
 | 
														</h3>
 | 
				
			||||||
											<h4 className="text-md font-semibold text-primary-900 dark:text-primary-200 mb-2">
 | 
														<ol className="list-decimal list-inside space-y-3 text-sm text-primary-800 dark:text-primary-300">
 | 
				
			||||||
												Automatic Docker Discovery
 | 
					 | 
				
			||||||
											</h4>
 | 
					 | 
				
			||||||
											<p className="text-sm text-primary-800 dark:text-primary-300 mb-3">
 | 
					 | 
				
			||||||
												The PatchMon Go agent automatically discovers Docker
 | 
					 | 
				
			||||||
												when it's available on your host and collects
 | 
					 | 
				
			||||||
												comprehensive inventory information:
 | 
					 | 
				
			||||||
											</p>
 | 
					 | 
				
			||||||
											<ul className="list-disc list-inside space-y-2 text-sm text-primary-800 dark:text-primary-300 ml-2">
 | 
					 | 
				
			||||||
												<li>
 | 
					 | 
				
			||||||
													<strong>Containers</strong> - Running and stopped
 | 
					 | 
				
			||||||
													containers with status, images, ports, and labels
 | 
					 | 
				
			||||||
												</li>
 | 
					 | 
				
			||||||
												<li>
 | 
					 | 
				
			||||||
													<strong>Images</strong> - All Docker images with
 | 
					 | 
				
			||||||
													repository, tags, sizes, and sources
 | 
					 | 
				
			||||||
												</li>
 | 
					 | 
				
			||||||
												<li>
 | 
					 | 
				
			||||||
													<strong>Volumes</strong> - Named and anonymous volumes
 | 
					 | 
				
			||||||
													with drivers, mountpoints, and usage
 | 
					 | 
				
			||||||
												</li>
 | 
					 | 
				
			||||||
												<li>
 | 
					 | 
				
			||||||
													<strong>Networks</strong> - Docker networks with
 | 
					 | 
				
			||||||
													drivers, IPAM configuration, and connected containers
 | 
					 | 
				
			||||||
												</li>
 | 
					 | 
				
			||||||
												<li>
 | 
					 | 
				
			||||||
													<strong>Real-time Updates</strong> - Container status
 | 
					 | 
				
			||||||
													changes are pushed instantly via WebSocket
 | 
					 | 
				
			||||||
												</li>
 | 
					 | 
				
			||||||
											</ul>
 | 
					 | 
				
			||||||
										</div>
 | 
					 | 
				
			||||||
									</div>
 | 
					 | 
				
			||||||
								</div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
								{/* How It Works */}
 | 
					 | 
				
			||||||
								<div className="bg-white dark:bg-secondary-900 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
 | 
					 | 
				
			||||||
									<h4 className="text-md font-semibold text-secondary-900 dark:text-white mb-4">
 | 
					 | 
				
			||||||
										How It Works
 | 
					 | 
				
			||||||
									</h4>
 | 
					 | 
				
			||||||
									<ol className="list-decimal list-inside space-y-3 text-sm text-secondary-700 dark:text-secondary-300">
 | 
					 | 
				
			||||||
										<li>
 | 
															<li>
 | 
				
			||||||
											Install the PatchMon Go agent on your host (see the Hosts
 | 
																Make sure you have the PatchMon credentials file set up on
 | 
				
			||||||
											page for installation instructions)
 | 
																your host (
 | 
				
			||||||
 | 
																<code className="bg-primary-100 dark:bg-primary-900/40 px-1 py-0.5 rounded text-xs">
 | 
				
			||||||
 | 
																	/etc/patchmon/credentials
 | 
				
			||||||
 | 
																</code>
 | 
				
			||||||
 | 
																)
 | 
				
			||||||
										</li>
 | 
															</li>
 | 
				
			||||||
										<li>
 | 
															<li>
 | 
				
			||||||
											The agent automatically detects if Docker is installed and
 | 
																SSH into your Docker host where you want to monitor
 | 
				
			||||||
											running on the host
 | 
																containers
 | 
				
			||||||
										</li>
 | 
															</li>
 | 
				
			||||||
 | 
															<li>Run the installation command below</li>
 | 
				
			||||||
										<li>
 | 
															<li>
 | 
				
			||||||
											During each collection cycle, the agent gathers Docker
 | 
																The agent will automatically collect Docker container and
 | 
				
			||||||
											inventory data and sends it to the PatchMon server
 | 
																image information every 5 minutes
 | 
				
			||||||
										</li>
 | 
					 | 
				
			||||||
										<li>
 | 
					 | 
				
			||||||
											View your complete Docker inventory (containers, images,
 | 
					 | 
				
			||||||
											volumes, networks) in the{" "}
 | 
					 | 
				
			||||||
											<a
 | 
					 | 
				
			||||||
												href="/docker"
 | 
					 | 
				
			||||||
												className="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 underline"
 | 
					 | 
				
			||||||
											>
 | 
					 | 
				
			||||||
												Docker page
 | 
					 | 
				
			||||||
											</a>
 | 
					 | 
				
			||||||
										</li>
 | 
					 | 
				
			||||||
										<li>
 | 
					 | 
				
			||||||
											Container status changes are pushed to the server in
 | 
					 | 
				
			||||||
											real-time via WebSocket connection
 | 
					 | 
				
			||||||
										</li>
 | 
															</li>
 | 
				
			||||||
 | 
															<li>View your Docker inventory in the Docker page</li>
 | 
				
			||||||
									</ol>
 | 
														</ol>
 | 
				
			||||||
								</div>
 | 
													</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
								{/* No Configuration Required */}
 | 
													{/* Installation Command */}
 | 
				
			||||||
								<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
 | 
													<div className="bg-white dark:bg-secondary-900 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
 | 
				
			||||||
									<div className="flex items-start gap-2">
 | 
														<h4 className="text-md font-semibold text-secondary-900 dark:text-white mb-3">
 | 
				
			||||||
										<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
 | 
															Quick Installation (One-Line Command)
 | 
				
			||||||
										<div className="text-sm text-green-800 dark:text-green-200">
 | 
														</h4>
 | 
				
			||||||
											<p className="font-semibold mb-1">
 | 
														<div className="space-y-3">
 | 
				
			||||||
												No Additional Configuration Required
 | 
															<div>
 | 
				
			||||||
											</p>
 | 
																<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
 | 
				
			||||||
											<p>
 | 
																	Download and install the Docker agent:
 | 
				
			||||||
												Once the Go agent is installed and Docker is running on
 | 
																</div>
 | 
				
			||||||
												your host, Docker inventory collection happens
 | 
																<div className="flex items-center gap-2">
 | 
				
			||||||
												automatically. No separate Docker agent or cron jobs
 | 
																	<input
 | 
				
			||||||
												needed.
 | 
																		type="text"
 | 
				
			||||||
 | 
																		value={`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent" && chmod +x /usr/local/bin/patchmon-docker-agent.sh && echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -`}
 | 
				
			||||||
 | 
																		readOnly
 | 
				
			||||||
 | 
																		className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
 | 
				
			||||||
 | 
																	/>
 | 
				
			||||||
 | 
																	<button
 | 
				
			||||||
 | 
																		type="button"
 | 
				
			||||||
 | 
																		onClick={() =>
 | 
				
			||||||
 | 
																			copy_to_clipboard(
 | 
				
			||||||
 | 
																				`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent" && chmod +x /usr/local/bin/patchmon-docker-agent.sh && echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -`,
 | 
				
			||||||
 | 
																				"docker-install",
 | 
				
			||||||
 | 
																			)
 | 
				
			||||||
 | 
																		}
 | 
				
			||||||
 | 
																		className="btn-primary flex items-center gap-1 px-3 py-2 whitespace-nowrap"
 | 
				
			||||||
 | 
																	>
 | 
				
			||||||
 | 
																		{copy_success["docker-install"] ? (
 | 
				
			||||||
 | 
																			<>
 | 
				
			||||||
 | 
																				<CheckCircle className="h-4 w-4" />
 | 
				
			||||||
 | 
																				Copied
 | 
				
			||||||
 | 
																			</>
 | 
				
			||||||
 | 
																		) : (
 | 
				
			||||||
 | 
																			<>
 | 
				
			||||||
 | 
																				<Copy className="h-4 w-4" />
 | 
				
			||||||
 | 
																				Copy
 | 
				
			||||||
 | 
																			</>
 | 
				
			||||||
 | 
																		)}
 | 
				
			||||||
 | 
																	</button>
 | 
				
			||||||
 | 
																</div>
 | 
				
			||||||
 | 
																<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-2">
 | 
				
			||||||
 | 
																	💡 This will download the agent, make it executable, and
 | 
				
			||||||
 | 
																	set up a cron job to run every 5 minutes
 | 
				
			||||||
											</p>
 | 
																</p>
 | 
				
			||||||
										</div>
 | 
															</div>
 | 
				
			||||||
									</div>
 | 
														</div>
 | 
				
			||||||
								</div>
 | 
													</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
								{/* Requirements */}
 | 
													{/* Manual Installation Steps */}
 | 
				
			||||||
								<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
 | 
													<div className="bg-white dark:bg-secondary-900 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
 | 
				
			||||||
 | 
														<h4 className="text-md font-semibold text-secondary-900 dark:text-white mb-3">
 | 
				
			||||||
 | 
															Manual Installation Steps
 | 
				
			||||||
 | 
														</h4>
 | 
				
			||||||
 | 
														<div className="space-y-4">
 | 
				
			||||||
 | 
															<div>
 | 
				
			||||||
 | 
																<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2">
 | 
				
			||||||
 | 
																	<strong>Step 1:</strong> Download the agent
 | 
				
			||||||
 | 
																</p>
 | 
				
			||||||
 | 
																<div className="flex items-center gap-2">
 | 
				
			||||||
 | 
																	<input
 | 
				
			||||||
 | 
																		type="text"
 | 
				
			||||||
 | 
																		value={`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent"`}
 | 
				
			||||||
 | 
																		readOnly
 | 
				
			||||||
 | 
																		className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
 | 
				
			||||||
 | 
																	/>
 | 
				
			||||||
 | 
																	<button
 | 
				
			||||||
 | 
																		type="button"
 | 
				
			||||||
 | 
																		onClick={() =>
 | 
				
			||||||
 | 
																			copy_to_clipboard(
 | 
				
			||||||
 | 
																				`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent"`,
 | 
				
			||||||
 | 
																				"docker-download",
 | 
				
			||||||
 | 
																			)
 | 
				
			||||||
 | 
																		}
 | 
				
			||||||
 | 
																		className="btn-primary p-2"
 | 
				
			||||||
 | 
																	>
 | 
				
			||||||
 | 
																		{copy_success["docker-download"] ? (
 | 
				
			||||||
 | 
																			<CheckCircle className="h-4 w-4" />
 | 
				
			||||||
 | 
																		) : (
 | 
				
			||||||
 | 
																			<Copy className="h-4 w-4" />
 | 
				
			||||||
 | 
																		)}
 | 
				
			||||||
 | 
																	</button>
 | 
				
			||||||
 | 
																</div>
 | 
				
			||||||
 | 
															</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
															<div>
 | 
				
			||||||
 | 
																<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2">
 | 
				
			||||||
 | 
																	<strong>Step 2:</strong> Make it executable
 | 
				
			||||||
 | 
																</p>
 | 
				
			||||||
 | 
																<div className="flex items-center gap-2">
 | 
				
			||||||
 | 
																	<input
 | 
				
			||||||
 | 
																		type="text"
 | 
				
			||||||
 | 
																		value="chmod +x /usr/local/bin/patchmon-docker-agent.sh"
 | 
				
			||||||
 | 
																		readOnly
 | 
				
			||||||
 | 
																		className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
 | 
				
			||||||
 | 
																	/>
 | 
				
			||||||
 | 
																	<button
 | 
				
			||||||
 | 
																		type="button"
 | 
				
			||||||
 | 
																		onClick={() =>
 | 
				
			||||||
 | 
																			copy_to_clipboard(
 | 
				
			||||||
 | 
																				"chmod +x /usr/local/bin/patchmon-docker-agent.sh",
 | 
				
			||||||
 | 
																				"docker-chmod",
 | 
				
			||||||
 | 
																			)
 | 
				
			||||||
 | 
																		}
 | 
				
			||||||
 | 
																		className="btn-primary p-2"
 | 
				
			||||||
 | 
																	>
 | 
				
			||||||
 | 
																		{copy_success["docker-chmod"] ? (
 | 
				
			||||||
 | 
																			<CheckCircle className="h-4 w-4" />
 | 
				
			||||||
 | 
																		) : (
 | 
				
			||||||
 | 
																			<Copy className="h-4 w-4" />
 | 
				
			||||||
 | 
																		)}
 | 
				
			||||||
 | 
																	</button>
 | 
				
			||||||
 | 
																</div>
 | 
				
			||||||
 | 
															</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
															<div>
 | 
				
			||||||
 | 
																<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2">
 | 
				
			||||||
 | 
																	<strong>Step 3:</strong> Test the agent
 | 
				
			||||||
 | 
																</p>
 | 
				
			||||||
 | 
																<div className="flex items-center gap-2">
 | 
				
			||||||
 | 
																	<input
 | 
				
			||||||
 | 
																		type="text"
 | 
				
			||||||
 | 
																		value="/usr/local/bin/patchmon-docker-agent.sh collect"
 | 
				
			||||||
 | 
																		readOnly
 | 
				
			||||||
 | 
																		className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
 | 
				
			||||||
 | 
																	/>
 | 
				
			||||||
 | 
																	<button
 | 
				
			||||||
 | 
																		type="button"
 | 
				
			||||||
 | 
																		onClick={() =>
 | 
				
			||||||
 | 
																			copy_to_clipboard(
 | 
				
			||||||
 | 
																				"/usr/local/bin/patchmon-docker-agent.sh collect",
 | 
				
			||||||
 | 
																				"docker-test",
 | 
				
			||||||
 | 
																			)
 | 
				
			||||||
 | 
																		}
 | 
				
			||||||
 | 
																		className="btn-primary p-2"
 | 
				
			||||||
 | 
																	>
 | 
				
			||||||
 | 
																		{copy_success["docker-test"] ? (
 | 
				
			||||||
 | 
																			<CheckCircle className="h-4 w-4" />
 | 
				
			||||||
 | 
																		) : (
 | 
				
			||||||
 | 
																			<Copy className="h-4 w-4" />
 | 
				
			||||||
 | 
																		)}
 | 
				
			||||||
 | 
																	</button>
 | 
				
			||||||
 | 
																</div>
 | 
				
			||||||
 | 
															</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
															<div>
 | 
				
			||||||
 | 
																<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2">
 | 
				
			||||||
 | 
																	<strong>Step 4:</strong> Set up automatic collection
 | 
				
			||||||
 | 
																	(every 5 minutes)
 | 
				
			||||||
 | 
																</p>
 | 
				
			||||||
 | 
																<div className="flex items-center gap-2">
 | 
				
			||||||
 | 
																	<input
 | 
				
			||||||
 | 
																		type="text"
 | 
				
			||||||
 | 
																		value='echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -'
 | 
				
			||||||
 | 
																		readOnly
 | 
				
			||||||
 | 
																		className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
 | 
				
			||||||
 | 
																	/>
 | 
				
			||||||
 | 
																	<button
 | 
				
			||||||
 | 
																		type="button"
 | 
				
			||||||
 | 
																		onClick={() =>
 | 
				
			||||||
 | 
																			copy_to_clipboard(
 | 
				
			||||||
 | 
																				'echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -',
 | 
				
			||||||
 | 
																				"docker-cron",
 | 
				
			||||||
 | 
																			)
 | 
				
			||||||
 | 
																		}
 | 
				
			||||||
 | 
																		className="btn-primary p-2"
 | 
				
			||||||
 | 
																	>
 | 
				
			||||||
 | 
																		{copy_success["docker-cron"] ? (
 | 
				
			||||||
 | 
																			<CheckCircle className="h-4 w-4" />
 | 
				
			||||||
 | 
																		) : (
 | 
				
			||||||
 | 
																			<Copy className="h-4 w-4" />
 | 
				
			||||||
 | 
																		)}
 | 
				
			||||||
 | 
																	</button>
 | 
				
			||||||
 | 
																</div>
 | 
				
			||||||
 | 
															</div>
 | 
				
			||||||
 | 
														</div>
 | 
				
			||||||
 | 
													</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
													{/* Prerequisites */}
 | 
				
			||||||
 | 
													<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
 | 
				
			||||||
									<div className="flex items-start gap-2">
 | 
														<div className="flex items-start gap-2">
 | 
				
			||||||
										<AlertCircle className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
 | 
															<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
 | 
				
			||||||
										<div className="text-sm text-blue-800 dark:text-blue-200">
 | 
															<div className="text-sm text-yellow-800 dark:text-yellow-200">
 | 
				
			||||||
											<p className="font-semibold mb-2">Requirements:</p>
 | 
																<p className="font-semibold mb-2">Prerequisites:</p>
 | 
				
			||||||
											<ul className="list-disc list-inside space-y-1 ml-2">
 | 
																<ul className="list-disc list-inside space-y-1 ml-2">
 | 
				
			||||||
												<li>PatchMon Go agent must be installed and running</li>
 | 
					 | 
				
			||||||
												<li>Docker daemon must be installed and running</li>
 | 
					 | 
				
			||||||
												<li>
 | 
																	<li>
 | 
				
			||||||
													Agent must have access to the Docker socket (
 | 
																		Docker must be installed and running on the host
 | 
				
			||||||
													<code className="bg-blue-100 dark:bg-blue-900/40 px-1 py-0.5 rounded text-xs">
 | 
																	</li>
 | 
				
			||||||
														/var/run/docker.sock
 | 
																	<li>
 | 
				
			||||||
 | 
																		PatchMon credentials file must exist at{" "}
 | 
				
			||||||
 | 
																		<code className="bg-yellow-100 dark:bg-yellow-900/40 px-1 py-0.5 rounded text-xs">
 | 
				
			||||||
 | 
																			/etc/patchmon/credentials
 | 
				
			||||||
													</code>
 | 
																		</code>
 | 
				
			||||||
													)
 | 
					 | 
				
			||||||
												</li>
 | 
																	</li>
 | 
				
			||||||
												<li>
 | 
																	<li>
 | 
				
			||||||
													Typically requires running the agent as root or with
 | 
																		The host must have network access to your PatchMon
 | 
				
			||||||
													Docker group permissions
 | 
																		server
 | 
				
			||||||
												</li>
 | 
																	</li>
 | 
				
			||||||
 | 
																	<li>The agent must run as root (or with sudo)</li>
 | 
				
			||||||
											</ul>
 | 
																</ul>
 | 
				
			||||||
										</div>
 | 
															</div>
 | 
				
			||||||
									</div>
 | 
														</div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -215,8 +215,8 @@ const SettingsHostGroups = () => {
 | 
				
			|||||||
													title={`View hosts in ${group.name}`}
 | 
																		title={`View hosts in ${group.name}`}
 | 
				
			||||||
												>
 | 
																	>
 | 
				
			||||||
													<Server className="h-4 w-4 mr-2" />
 | 
																		<Server className="h-4 w-4 mr-2" />
 | 
				
			||||||
													{group._count?.hosts || 0} host
 | 
																		{group._count.hosts} host
 | 
				
			||||||
													{group._count?.hosts !== 1 ? "s" : ""}
 | 
																		{group._count.hosts !== 1 ? "s" : ""}
 | 
				
			||||||
												</button>
 | 
																	</button>
 | 
				
			||||||
											</td>
 | 
																</td>
 | 
				
			||||||
											<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
 | 
																<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
 | 
				
			||||||
@@ -539,18 +539,9 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// Delete Confirmation Modal
 | 
					// Delete Confirmation Modal
 | 
				
			||||||
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
 | 
					const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
 | 
				
			||||||
	// Fetch hosts for this group
 | 
					 | 
				
			||||||
	const { data: hostsData } = useQuery({
 | 
					 | 
				
			||||||
		queryKey: ["hostGroupHosts", group?.id],
 | 
					 | 
				
			||||||
		queryFn: () => hostGroupsAPI.getHosts(group.id).then((res) => res.data),
 | 
					 | 
				
			||||||
		enabled: !!group && group._count?.hosts > 0,
 | 
					 | 
				
			||||||
	});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	const hosts = hostsData || [];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	return (
 | 
						return (
 | 
				
			||||||
		<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
 | 
							<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
 | 
				
			||||||
			<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
 | 
								<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
 | 
				
			||||||
				<div className="flex items-center gap-3 mb-4">
 | 
									<div className="flex items-center gap-3 mb-4">
 | 
				
			||||||
					<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
 | 
										<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
 | 
				
			||||||
						<AlertTriangle className="h-5 w-5 text-danger-600" />
 | 
											<AlertTriangle className="h-5 w-5 text-danger-600" />
 | 
				
			||||||
@@ -570,32 +561,14 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
 | 
				
			|||||||
						Are you sure you want to delete the host group{" "}
 | 
											Are you sure you want to delete the host group{" "}
 | 
				
			||||||
						<span className="font-semibold">"{group.name}"</span>?
 | 
											<span className="font-semibold">"{group.name}"</span>?
 | 
				
			||||||
					</p>
 | 
										</p>
 | 
				
			||||||
					{group._count?.hosts > 0 && (
 | 
										{group._count.hosts > 0 && (
 | 
				
			||||||
						<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-md">
 | 
											<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-md">
 | 
				
			||||||
							<p className="text-sm text-blue-800 mb-2">
 | 
												<p className="text-sm text-blue-800">
 | 
				
			||||||
								<strong>Note:</strong> This group contains {group._count?.hosts}{" "}
 | 
													<strong>Note:</strong> This group contains {group._count.hosts}{" "}
 | 
				
			||||||
								host
 | 
													host
 | 
				
			||||||
								{group._count?.hosts !== 1 ? "s" : ""}. These hosts will be
 | 
													{group._count.hosts !== 1 ? "s" : ""}. These hosts will be moved
 | 
				
			||||||
								moved to "No group" after deletion.
 | 
													to "No group" after deletion.
 | 
				
			||||||
							</p>
 | 
												</p>
 | 
				
			||||||
							{hosts.length > 0 && (
 | 
					 | 
				
			||||||
								<div className="mt-2">
 | 
					 | 
				
			||||||
									<p className="text-xs font-medium text-blue-900 mb-1">
 | 
					 | 
				
			||||||
										Hosts in this group:
 | 
					 | 
				
			||||||
									</p>
 | 
					 | 
				
			||||||
									<div className="max-h-32 overflow-y-auto bg-blue-100 rounded p-2">
 | 
					 | 
				
			||||||
										{hosts.map((host) => (
 | 
					 | 
				
			||||||
											<div
 | 
					 | 
				
			||||||
												key={host.id}
 | 
					 | 
				
			||||||
												className="text-xs text-blue-900 flex items-center gap-1"
 | 
					 | 
				
			||||||
											>
 | 
					 | 
				
			||||||
												<Server className="h-3 w-3" />
 | 
					 | 
				
			||||||
												{host.friendly_name || host.hostname}
 | 
					 | 
				
			||||||
											</div>
 | 
					 | 
				
			||||||
										))}
 | 
					 | 
				
			||||||
									</div>
 | 
					 | 
				
			||||||
								</div>
 | 
					 | 
				
			||||||
							)}
 | 
					 | 
				
			||||||
						</div>
 | 
											</div>
 | 
				
			||||||
					)}
 | 
										)}
 | 
				
			||||||
				</div>
 | 
									</div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,7 +2,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 | 
				
			|||||||
import {
 | 
					import {
 | 
				
			||||||
	AlertCircle,
 | 
						AlertCircle,
 | 
				
			||||||
	BarChart3,
 | 
						BarChart3,
 | 
				
			||||||
	BookOpen,
 | 
					 | 
				
			||||||
	CheckCircle,
 | 
						CheckCircle,
 | 
				
			||||||
	Eye,
 | 
						Eye,
 | 
				
			||||||
	EyeOff,
 | 
						EyeOff,
 | 
				
			||||||
@@ -179,19 +178,6 @@ const SettingsMetrics = () => {
 | 
				
			|||||||
							</div>
 | 
												</div>
 | 
				
			||||||
						</div>
 | 
											</div>
 | 
				
			||||||
					</div>
 | 
										</div>
 | 
				
			||||||
 | 
					 | 
				
			||||||
					{/* More Information Button */}
 | 
					 | 
				
			||||||
					<div className="mt-4 pt-4 border-t border-blue-200 dark:border-blue-700">
 | 
					 | 
				
			||||||
						<a
 | 
					 | 
				
			||||||
							href="https://docs.patchmon.net/books/patchmon-application-documentation/page/metrics-collection-information"
 | 
					 | 
				
			||||||
							target="_blank"
 | 
					 | 
				
			||||||
							rel="noopener noreferrer"
 | 
					 | 
				
			||||||
							className="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 rounded-md hover:bg-blue-200 dark:hover:bg-blue-900/70 transition-colors"
 | 
					 | 
				
			||||||
						>
 | 
					 | 
				
			||||||
							<BookOpen className="h-4 w-4 mr-2" />
 | 
					 | 
				
			||||||
							More Information
 | 
					 | 
				
			||||||
						</a>
 | 
					 | 
				
			||||||
					</div>
 | 
					 | 
				
			||||||
				</div>
 | 
									</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
				{/* Metrics Toggle */}
 | 
									{/* Metrics Toggle */}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -19,30 +19,6 @@ api.interceptors.request.use(
 | 
				
			|||||||
		if (token) {
 | 
							if (token) {
 | 
				
			||||||
			config.headers.Authorization = `Bearer ${token}`;
 | 
								config.headers.Authorization = `Bearer ${token}`;
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					 | 
				
			||||||
		// Add device ID for TFA remember-me functionality
 | 
					 | 
				
			||||||
		// This uniquely identifies the browser profile (normal vs incognito)
 | 
					 | 
				
			||||||
		let deviceId = localStorage.getItem("device_id");
 | 
					 | 
				
			||||||
		if (!deviceId) {
 | 
					 | 
				
			||||||
			// Generate a unique device ID and store it
 | 
					 | 
				
			||||||
			// Use crypto.randomUUID() if available, otherwise generate a UUID v4 manually
 | 
					 | 
				
			||||||
			if (typeof crypto !== "undefined" && crypto.randomUUID) {
 | 
					 | 
				
			||||||
				deviceId = crypto.randomUUID();
 | 
					 | 
				
			||||||
			} else {
 | 
					 | 
				
			||||||
				// Fallback: Generate UUID v4 manually
 | 
					 | 
				
			||||||
				deviceId = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
 | 
					 | 
				
			||||||
					/[xy]/g,
 | 
					 | 
				
			||||||
					(c) => {
 | 
					 | 
				
			||||||
						const r = (Math.random() * 16) | 0;
 | 
					 | 
				
			||||||
						const v = c === "x" ? r : (r & 0x3) | 0x8;
 | 
					 | 
				
			||||||
						return v.toString(16);
 | 
					 | 
				
			||||||
					},
 | 
					 | 
				
			||||||
				);
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			localStorage.setItem("device_id", deviceId);
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
		config.headers["X-Device-ID"] = deviceId;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		return config;
 | 
							return config;
 | 
				
			||||||
	},
 | 
						},
 | 
				
			||||||
	(error) => {
 | 
						(error) => {
 | 
				
			||||||
@@ -119,8 +95,6 @@ export const adminHostsAPI = {
 | 
				
			|||||||
		api.put("/hosts/bulk/groups", { hostIds, groupIds }),
 | 
							api.put("/hosts/bulk/groups", { hostIds, groupIds }),
 | 
				
			||||||
	toggleAutoUpdate: (hostId, autoUpdate) =>
 | 
						toggleAutoUpdate: (hostId, autoUpdate) =>
 | 
				
			||||||
		api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }),
 | 
							api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }),
 | 
				
			||||||
	forceAgentUpdate: (hostId) => api.post(`/hosts/${hostId}/force-agent-update`),
 | 
					 | 
				
			||||||
	fetchReport: (hostId) => api.post(`/hosts/${hostId}/fetch-report`),
 | 
					 | 
				
			||||||
	updateFriendlyName: (hostId, friendlyName) =>
 | 
						updateFriendlyName: (hostId, friendlyName) =>
 | 
				
			||||||
		api.patch(`/hosts/${hostId}/friendly-name`, {
 | 
							api.patch(`/hosts/${hostId}/friendly-name`, {
 | 
				
			||||||
			friendly_name: friendlyName,
 | 
								friendly_name: friendlyName,
 | 
				
			||||||
@@ -169,12 +143,6 @@ export const settingsAPI = {
 | 
				
			|||||||
	getServerUrl: () => api.get("/settings/server-url"),
 | 
						getServerUrl: () => api.get("/settings/server-url"),
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// User Preferences API
 | 
					 | 
				
			||||||
export const userPreferencesAPI = {
 | 
					 | 
				
			||||||
	get: () => api.get("/user/preferences"),
 | 
					 | 
				
			||||||
	update: (preferences) => api.patch("/user/preferences", preferences),
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Agent File Management API
 | 
					// Agent File Management API
 | 
				
			||||||
export const agentFileAPI = {
 | 
					export const agentFileAPI = {
 | 
				
			||||||
	getInfo: () => api.get("/hosts/agent/info"),
 | 
						getInfo: () => api.get("/hosts/agent/info"),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,171 +0,0 @@
 | 
				
			|||||||
/**
 | 
					 | 
				
			||||||
 * Docker-related utility functions for the frontend
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Generate a registry link for a Docker image based on its repository and source
 | 
					 | 
				
			||||||
 * @param {string} repository - The full repository name (e.g., "ghcr.io/owner/repo")
 | 
					 | 
				
			||||||
 * @param {string} source - The detected source (github, gitlab, docker-hub, etc.)
 | 
					 | 
				
			||||||
 * @returns {string|null} - The URL to the registry page, or null if unknown
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
export function generateRegistryLink(repository, source) {
 | 
					 | 
				
			||||||
	if (!repository) {
 | 
					 | 
				
			||||||
		return null;
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Parse the domain and path from the repository
 | 
					 | 
				
			||||||
	const parts = repository.split("/");
 | 
					 | 
				
			||||||
	let domain = "";
 | 
					 | 
				
			||||||
	let path = "";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Check if repository has a domain (contains a dot)
 | 
					 | 
				
			||||||
	if (parts[0].includes(".") || parts[0].includes(":")) {
 | 
					 | 
				
			||||||
		domain = parts[0];
 | 
					 | 
				
			||||||
		path = parts.slice(1).join("/");
 | 
					 | 
				
			||||||
	} else {
 | 
					 | 
				
			||||||
		// No domain means Docker Hub
 | 
					 | 
				
			||||||
		domain = "docker.io";
 | 
					 | 
				
			||||||
		path = repository;
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	switch (source) {
 | 
					 | 
				
			||||||
		case "docker-hub":
 | 
					 | 
				
			||||||
		case "docker.io": {
 | 
					 | 
				
			||||||
			// Docker Hub: https://hub.docker.com/r/{path} or https://hub.docker.com/_/{path} for official images
 | 
					 | 
				
			||||||
			// Official images are those without a namespace (e.g., "postgres" not "user/postgres")
 | 
					 | 
				
			||||||
			// or explicitly prefixed with "library/"
 | 
					 | 
				
			||||||
			if (path.startsWith("library/")) {
 | 
					 | 
				
			||||||
				const cleanPath = path.replace("library/", "");
 | 
					 | 
				
			||||||
				return `https://hub.docker.com/_/${cleanPath}`;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			// Check if it's an official image (single part, no slash after removing library/)
 | 
					 | 
				
			||||||
			if (!path.includes("/")) {
 | 
					 | 
				
			||||||
				return `https://hub.docker.com/_/${path}`;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			// Regular user/org image
 | 
					 | 
				
			||||||
			return `https://hub.docker.com/r/${path}`;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		case "github":
 | 
					 | 
				
			||||||
		case "ghcr.io": {
 | 
					 | 
				
			||||||
			// GitHub Container Registry
 | 
					 | 
				
			||||||
			// Format: ghcr.io/{owner}/{package} or ghcr.io/{owner}/{repo}/{package}
 | 
					 | 
				
			||||||
			// URL format: https://github.com/{owner}/{repo}/pkgs/container/{package}
 | 
					 | 
				
			||||||
			if (domain === "ghcr.io" && path) {
 | 
					 | 
				
			||||||
				const pathParts = path.split("/");
 | 
					 | 
				
			||||||
				if (pathParts.length === 2) {
 | 
					 | 
				
			||||||
					// Simple case: ghcr.io/owner/package -> github.com/owner/owner/pkgs/container/package
 | 
					 | 
				
			||||||
					// OR: ghcr.io/owner/repo -> github.com/owner/repo/pkgs/container/{package}
 | 
					 | 
				
			||||||
					// Actually, for 2 parts it's owner/package, and repo is same as owner typically
 | 
					 | 
				
			||||||
					const owner = pathParts[0];
 | 
					 | 
				
			||||||
					const packageName = pathParts[1];
 | 
					 | 
				
			||||||
					return `https://github.com/${owner}/${owner}/pkgs/container/${packageName}`;
 | 
					 | 
				
			||||||
				} else if (pathParts.length >= 3) {
 | 
					 | 
				
			||||||
					// Extended case: ghcr.io/owner/repo/package -> github.com/owner/repo/pkgs/container/package
 | 
					 | 
				
			||||||
					const owner = pathParts[0];
 | 
					 | 
				
			||||||
					const repo = pathParts[1];
 | 
					 | 
				
			||||||
					const packageName = pathParts.slice(2).join("/");
 | 
					 | 
				
			||||||
					return `https://github.com/${owner}/${repo}/pkgs/container/${packageName}`;
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			// Legacy GitHub Packages
 | 
					 | 
				
			||||||
			if (domain === "docker.pkg.github.com" && path) {
 | 
					 | 
				
			||||||
				const pathParts = path.split("/");
 | 
					 | 
				
			||||||
				if (pathParts.length >= 1) {
 | 
					 | 
				
			||||||
					return `https://github.com/${pathParts[0]}/packages`;
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			return null;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		case "gitlab":
 | 
					 | 
				
			||||||
		case "registry.gitlab.com": {
 | 
					 | 
				
			||||||
			// GitLab Container Registry
 | 
					 | 
				
			||||||
			if (path) {
 | 
					 | 
				
			||||||
				return `https://gitlab.com/${path}/container_registry`;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			return null;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		case "google":
 | 
					 | 
				
			||||||
		case "gcr.io": {
 | 
					 | 
				
			||||||
			// Google Container Registry
 | 
					 | 
				
			||||||
			if (domain.includes("gcr.io") || domain.includes("pkg.dev")) {
 | 
					 | 
				
			||||||
				return `https://console.cloud.google.com/gcr/images/${path}`;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			return null;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		case "quay":
 | 
					 | 
				
			||||||
		case "quay.io": {
 | 
					 | 
				
			||||||
			// Quay.io
 | 
					 | 
				
			||||||
			if (path) {
 | 
					 | 
				
			||||||
				return `https://quay.io/repository/${path}`;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			return null;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		case "redhat":
 | 
					 | 
				
			||||||
		case "registry.access.redhat.com": {
 | 
					 | 
				
			||||||
			// Red Hat
 | 
					 | 
				
			||||||
			if (path) {
 | 
					 | 
				
			||||||
				return `https://access.redhat.com/containers/#/registry.access.redhat.com/${path}`;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			return null;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		case "azure":
 | 
					 | 
				
			||||||
		case "azurecr.io": {
 | 
					 | 
				
			||||||
			// Azure Container Registry
 | 
					 | 
				
			||||||
			if (domain.includes("azurecr.io")) {
 | 
					 | 
				
			||||||
				const registryName = domain.split(".")[0];
 | 
					 | 
				
			||||||
				return `https://portal.azure.com/#view/Microsoft_Azure_ContainerRegistries/RepositoryBlade/registryName/${registryName}/repositoryName/${path}`;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			return null;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		case "aws":
 | 
					 | 
				
			||||||
		case "amazonaws.com": {
 | 
					 | 
				
			||||||
			// AWS ECR
 | 
					 | 
				
			||||||
			if (domain.includes("amazonaws.com")) {
 | 
					 | 
				
			||||||
				const domainParts = domain.split(".");
 | 
					 | 
				
			||||||
				const region = domainParts[3]; // Extract region
 | 
					 | 
				
			||||||
				return `https://${region}.console.aws.amazon.com/ecr/repositories/private/${path}`;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			return null;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		case "private":
 | 
					 | 
				
			||||||
			// For private registries, try to construct a basic URL
 | 
					 | 
				
			||||||
			if (domain) {
 | 
					 | 
				
			||||||
				return `https://${domain}`;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			return null;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		default:
 | 
					 | 
				
			||||||
			return null;
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Get a user-friendly display name for a registry source
 | 
					 | 
				
			||||||
 * @param {string} source - The source identifier
 | 
					 | 
				
			||||||
 * @returns {string} - Human-readable source name
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
export function getSourceDisplayName(source) {
 | 
					 | 
				
			||||||
	const sourceNames = {
 | 
					 | 
				
			||||||
		"docker-hub": "Docker Hub",
 | 
					 | 
				
			||||||
		github: "GitHub",
 | 
					 | 
				
			||||||
		gitlab: "GitLab",
 | 
					 | 
				
			||||||
		google: "Google",
 | 
					 | 
				
			||||||
		quay: "Quay.io",
 | 
					 | 
				
			||||||
		redhat: "Red Hat",
 | 
					 | 
				
			||||||
		azure: "Azure",
 | 
					 | 
				
			||||||
		aws: "AWS ECR",
 | 
					 | 
				
			||||||
		private: "Private Registry",
 | 
					 | 
				
			||||||
		local: "Local",
 | 
					 | 
				
			||||||
		unknown: "Unknown",
 | 
					 | 
				
			||||||
	};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	return sourceNames[source] || source;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
							
								
								
									
										706
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										706
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,6 +1,6 @@
 | 
				
			|||||||
{
 | 
					{
 | 
				
			||||||
	"name": "patchmon",
 | 
						"name": "patchmon",
 | 
				
			||||||
	"version": "1.3.2",
 | 
						"version": "1.3.1",
 | 
				
			||||||
	"description": "Linux Patch Monitoring System",
 | 
						"description": "Linux Patch Monitoring System",
 | 
				
			||||||
	"license": "AGPL-3.0",
 | 
						"license": "AGPL-3.0",
 | 
				
			||||||
	"private": true,
 | 
						"private": true,
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										185
									
								
								setup.sh
									
									
									
									
									
								
							
							
						
						
									
										185
									
								
								setup.sh
									
									
									
									
									
								
							@@ -34,7 +34,7 @@ BLUE='\033[0;34m'
 | 
				
			|||||||
NC='\033[0m' # No Color
 | 
					NC='\033[0m' # No Color
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Global variables
 | 
					# Global variables
 | 
				
			||||||
SCRIPT_VERSION="self-hosting-install.sh v1.3.2-selfhost-2025-10-31-1"
 | 
					SCRIPT_VERSION="self-hosting-install.sh v1.3.0-selfhost-2025-10-19-1"
 | 
				
			||||||
DEFAULT_GITHUB_REPO="https://github.com/PatchMon/PatchMon.git"
 | 
					DEFAULT_GITHUB_REPO="https://github.com/PatchMon/PatchMon.git"
 | 
				
			||||||
FQDN=""
 | 
					FQDN=""
 | 
				
			||||||
CUSTOM_FQDN=""
 | 
					CUSTOM_FQDN=""
 | 
				
			||||||
@@ -1797,12 +1797,7 @@ create_agent_version() {
 | 
				
			|||||||
        cp "$APP_DIR/agents/patchmon-agent.sh" "$APP_DIR/backend/"
 | 
					        cp "$APP_DIR/agents/patchmon-agent.sh" "$APP_DIR/backend/"
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        print_status "Agent version management removed - using file-based approach"
 | 
					        print_status "Agent version management removed - using file-based approach"
 | 
				
			||||||
    fi
 | 
					# Ensure we close the conditional and the function properly
 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Make agent binaries executable
 | 
					 | 
				
			||||||
    if [ -d "$APP_DIR/agents" ]; then
 | 
					 | 
				
			||||||
        chmod +x "$APP_DIR/agents/patchmon-agent-linux-"* 2>/dev/null || true
 | 
					 | 
				
			||||||
        print_status "Agent binaries made executable"
 | 
					 | 
				
			||||||
    fi
 | 
					    fi
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return 0
 | 
					    return 0
 | 
				
			||||||
@@ -2197,66 +2192,34 @@ select_installation_to_update() {
 | 
				
			|||||||
            version=$(grep '"version"' "/opt/$install/backend/package.json" | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/')
 | 
					            version=$(grep '"version"' "/opt/$install/backend/package.json" | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/')
 | 
				
			||||||
        fi
 | 
					        fi
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        # Get service status - search for service files that reference this installation
 | 
					        # Get service status - try multiple naming conventions
 | 
				
			||||||
        local service_name=""
 | 
					        # Convention 1: Just the install name (e.g., patchmon.internal)
 | 
				
			||||||
 | 
					        local service_name="$install"
 | 
				
			||||||
 | 
					        # Convention 2: patchmon. prefix (e.g., patchmon.patchmon.internal)
 | 
				
			||||||
 | 
					        local alt_service_name1="patchmon.$install"
 | 
				
			||||||
 | 
					        # Convention 3: patchmon- prefix with underscores (e.g., patchmon-patchmon_internal)
 | 
				
			||||||
 | 
					        local alt_service_name2="patchmon-$(echo "$install" | tr '.' '_')"
 | 
				
			||||||
        local status="unknown"
 | 
					        local status="unknown"
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        # Search systemd directory for service files that reference this installation
 | 
					        # Try convention 1 first (most common)
 | 
				
			||||||
        for service_file in /etc/systemd/system/*.service; do
 | 
					        if systemctl is-active --quiet "$service_name" 2>/dev/null; then
 | 
				
			||||||
            if [ -f "$service_file" ]; then
 | 
					            status="running"
 | 
				
			||||||
                # Check if this service file references our installation directory
 | 
					        elif systemctl is-enabled --quiet "$service_name" 2>/dev/null; then
 | 
				
			||||||
                if grep -q "/opt/$install" "$service_file"; then
 | 
					            status="stopped"
 | 
				
			||||||
                    service_name=$(basename "$service_file" .service)
 | 
					        # Try convention 2
 | 
				
			||||||
                    
 | 
					        elif systemctl is-active --quiet "$alt_service_name1" 2>/dev/null; then
 | 
				
			||||||
                    # Check service status
 | 
					            status="running"
 | 
				
			||||||
                    if systemctl is-active --quiet "$service_name" 2>/dev/null; then
 | 
					            service_name="$alt_service_name1"
 | 
				
			||||||
                        status="running"
 | 
					        elif systemctl is-enabled --quiet "$alt_service_name1" 2>/dev/null; then
 | 
				
			||||||
                        break
 | 
					            status="stopped"
 | 
				
			||||||
                    elif systemctl is-enabled --quiet "$service_name" 2>/dev/null; then
 | 
					            service_name="$alt_service_name1"
 | 
				
			||||||
                        status="stopped"
 | 
					        # Try convention 3
 | 
				
			||||||
                        break
 | 
					        elif systemctl is-active --quiet "$alt_service_name2" 2>/dev/null; then
 | 
				
			||||||
                    fi
 | 
					            status="running"
 | 
				
			||||||
                fi
 | 
					            service_name="$alt_service_name2"
 | 
				
			||||||
            fi
 | 
					        elif systemctl is-enabled --quiet "$alt_service_name2" 2>/dev/null; then
 | 
				
			||||||
        done
 | 
					            status="stopped"
 | 
				
			||||||
        
 | 
					            service_name="$alt_service_name2"
 | 
				
			||||||
        # If not found by searching, try common naming conventions
 | 
					 | 
				
			||||||
        if [ -z "$service_name" ] || [ "$status" == "unknown" ]; then
 | 
					 | 
				
			||||||
            # Convention 1: Just the install name (e.g., patchmon.internal)
 | 
					 | 
				
			||||||
            local try_service="$install"
 | 
					 | 
				
			||||||
            # Convention 2: patchmon. prefix (e.g., patchmon.patchmon.internal)
 | 
					 | 
				
			||||||
            local alt_service_name1="patchmon.$install"
 | 
					 | 
				
			||||||
            # Convention 3: patchmon- prefix with underscores (e.g., patchmon-patchmon_internal)
 | 
					 | 
				
			||||||
            local alt_service_name2="patchmon-$(echo "$install" | tr '.' '_')"
 | 
					 | 
				
			||||||
            
 | 
					 | 
				
			||||||
            # Try convention 1 first (most common)
 | 
					 | 
				
			||||||
            if systemctl is-active --quiet "$try_service" 2>/dev/null; then
 | 
					 | 
				
			||||||
                status="running"
 | 
					 | 
				
			||||||
                service_name="$try_service"
 | 
					 | 
				
			||||||
            elif systemctl is-enabled --quiet "$try_service" 2>/dev/null; then
 | 
					 | 
				
			||||||
                status="stopped"
 | 
					 | 
				
			||||||
                service_name="$try_service"
 | 
					 | 
				
			||||||
            # Try convention 2
 | 
					 | 
				
			||||||
            elif systemctl is-active --quiet "$alt_service_name1" 2>/dev/null; then
 | 
					 | 
				
			||||||
                status="running"
 | 
					 | 
				
			||||||
                service_name="$alt_service_name1"
 | 
					 | 
				
			||||||
            elif systemctl is-enabled --quiet "$alt_service_name1" 2>/dev/null; then
 | 
					 | 
				
			||||||
                status="stopped"
 | 
					 | 
				
			||||||
                service_name="$alt_service_name1"
 | 
					 | 
				
			||||||
            # Try convention 3
 | 
					 | 
				
			||||||
            elif systemctl is-active --quiet "$alt_service_name2" 2>/dev/null; then
 | 
					 | 
				
			||||||
                status="running"
 | 
					 | 
				
			||||||
                service_name="$alt_service_name2"
 | 
					 | 
				
			||||||
            elif systemctl is-enabled --quiet "$alt_service_name2" 2>/dev/null; then
 | 
					 | 
				
			||||||
                status="stopped"
 | 
					 | 
				
			||||||
                service_name="$alt_service_name2"
 | 
					 | 
				
			||||||
            fi
 | 
					 | 
				
			||||||
        fi
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        # Fallback: if still no service found, use default naming convention
 | 
					 | 
				
			||||||
        if [ -z "$service_name" ]; then
 | 
					 | 
				
			||||||
            service_name="$install"
 | 
					 | 
				
			||||||
            status="not_found"
 | 
					 | 
				
			||||||
        fi
 | 
					        fi
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        printf "%2d. %-30s (v%-10s - %s)\n" "$i" "$install" "$version" "$status"
 | 
					        printf "%2d. %-30s (v%-10s - %s)\n" "$i" "$install" "$version" "$status"
 | 
				
			||||||
@@ -2740,13 +2703,6 @@ update_env_file() {
 | 
				
			|||||||
    : ${TFA_MAX_REMEMBER_SESSIONS:=5}
 | 
					    : ${TFA_MAX_REMEMBER_SESSIONS:=5}
 | 
				
			||||||
    : ${TFA_SUSPICIOUS_ACTIVITY_THRESHOLD:=3}
 | 
					    : ${TFA_SUSPICIOUS_ACTIVITY_THRESHOLD:=3}
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    # Prisma Connection Pool
 | 
					 | 
				
			||||||
    : ${DB_CONNECTION_LIMIT:=30}
 | 
					 | 
				
			||||||
    : ${DB_POOL_TIMEOUT:=20}
 | 
					 | 
				
			||||||
    : ${DB_CONNECT_TIMEOUT:=10}
 | 
					 | 
				
			||||||
    : ${DB_IDLE_TIMEOUT:=300}
 | 
					 | 
				
			||||||
    : ${DB_MAX_LIFETIME:=1800}
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Track which variables were added
 | 
					    # Track which variables were added
 | 
				
			||||||
    local added_vars=()
 | 
					    local added_vars=()
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
@@ -2808,21 +2764,6 @@ update_env_file() {
 | 
				
			|||||||
    if ! grep -q "^TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=" "$env_file"; then
 | 
					    if ! grep -q "^TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=" "$env_file"; then
 | 
				
			||||||
        added_vars+=("TFA_SUSPICIOUS_ACTIVITY_THRESHOLD")
 | 
					        added_vars+=("TFA_SUSPICIOUS_ACTIVITY_THRESHOLD")
 | 
				
			||||||
    fi
 | 
					    fi
 | 
				
			||||||
    if ! grep -q "^DB_CONNECTION_LIMIT=" "$env_file"; then
 | 
					 | 
				
			||||||
        added_vars+=("DB_CONNECTION_LIMIT")
 | 
					 | 
				
			||||||
    fi
 | 
					 | 
				
			||||||
    if ! grep -q "^DB_POOL_TIMEOUT=" "$env_file"; then
 | 
					 | 
				
			||||||
        added_vars+=("DB_POOL_TIMEOUT")
 | 
					 | 
				
			||||||
    fi
 | 
					 | 
				
			||||||
    if ! grep -q "^DB_CONNECT_TIMEOUT=" "$env_file"; then
 | 
					 | 
				
			||||||
        added_vars+=("DB_CONNECT_TIMEOUT")
 | 
					 | 
				
			||||||
    fi
 | 
					 | 
				
			||||||
    if ! grep -q "^DB_IDLE_TIMEOUT=" "$env_file"; then
 | 
					 | 
				
			||||||
        added_vars+=("DB_IDLE_TIMEOUT")
 | 
					 | 
				
			||||||
    fi
 | 
					 | 
				
			||||||
    if ! grep -q "^DB_MAX_LIFETIME=" "$env_file"; then
 | 
					 | 
				
			||||||
        added_vars+=("DB_MAX_LIFETIME")
 | 
					 | 
				
			||||||
    fi
 | 
					 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    # If there are missing variables, add them
 | 
					    # If there are missing variables, add them
 | 
				
			||||||
    if [ ${#added_vars[@]} -gt 0 ]; then
 | 
					    if [ ${#added_vars[@]} -gt 0 ]; then
 | 
				
			||||||
@@ -2908,25 +2849,6 @@ EOF
 | 
				
			|||||||
            echo "TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=$TFA_SUSPICIOUS_ACTIVITY_THRESHOLD" >> "$env_file"
 | 
					            echo "TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=$TFA_SUSPICIOUS_ACTIVITY_THRESHOLD" >> "$env_file"
 | 
				
			||||||
        fi
 | 
					        fi
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        # Add Prisma connection pool config if missing
 | 
					 | 
				
			||||||
        if printf '%s\n' "${added_vars[@]}" | grep -q "DB_CONNECTION_LIMIT"; then
 | 
					 | 
				
			||||||
            echo "" >> "$env_file"
 | 
					 | 
				
			||||||
            echo "# Database Connection Pool Configuration (Prisma)" >> "$env_file"
 | 
					 | 
				
			||||||
            echo "DB_CONNECTION_LIMIT=$DB_CONNECTION_LIMIT" >> "$env_file"
 | 
					 | 
				
			||||||
        fi
 | 
					 | 
				
			||||||
        if printf '%s\n' "${added_vars[@]}" | grep -q "DB_POOL_TIMEOUT"; then
 | 
					 | 
				
			||||||
            echo "DB_POOL_TIMEOUT=$DB_POOL_TIMEOUT" >> "$env_file"
 | 
					 | 
				
			||||||
        fi
 | 
					 | 
				
			||||||
        if printf '%s\n' "${added_vars[@]}" | grep -q "DB_CONNECT_TIMEOUT"; then
 | 
					 | 
				
			||||||
            echo "DB_CONNECT_TIMEOUT=$DB_CONNECT_TIMEOUT" >> "$env_file"
 | 
					 | 
				
			||||||
        fi
 | 
					 | 
				
			||||||
        if printf '%s\n' "${added_vars[@]}" | grep -q "DB_IDLE_TIMEOUT"; then
 | 
					 | 
				
			||||||
            echo "DB_IDLE_TIMEOUT=$DB_IDLE_TIMEOUT" >> "$env_file"
 | 
					 | 
				
			||||||
        fi
 | 
					 | 
				
			||||||
        if printf '%s\n' "${added_vars[@]}" | grep -q "DB_MAX_LIFETIME"; then
 | 
					 | 
				
			||||||
            echo "DB_MAX_LIFETIME=$DB_MAX_LIFETIME" >> "$env_file"
 | 
					 | 
				
			||||||
        fi
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        print_status ".env file updated with ${#added_vars[@]} new variable(s)"
 | 
					        print_status ".env file updated with ${#added_vars[@]} new variable(s)"
 | 
				
			||||||
        print_info "Added variables: ${added_vars[*]}"
 | 
					        print_info "Added variables: ${added_vars[*]}"
 | 
				
			||||||
    else
 | 
					    else
 | 
				
			||||||
@@ -2996,37 +2918,11 @@ update_installation() {
 | 
				
			|||||||
    print_info "Installation directory: $instance_dir"
 | 
					    print_info "Installation directory: $instance_dir"
 | 
				
			||||||
    print_info "Service name: $service_name"
 | 
					    print_info "Service name: $service_name"
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    # Verify it's a git repository, if not, initialize it
 | 
					    # Verify it's a git repository
 | 
				
			||||||
    if [ ! -d "$instance_dir/.git" ]; then
 | 
					    if [ ! -d "$instance_dir/.git" ]; then
 | 
				
			||||||
        print_warning "Installation directory is not a git repository"
 | 
					        print_error "Installation directory is not a git repository"
 | 
				
			||||||
        print_info "Attempting to re-initialize as git repository..."
 | 
					        print_error "Cannot perform git-based update"
 | 
				
			||||||
        
 | 
					        exit 1
 | 
				
			||||||
        cd "$instance_dir" || exit 1
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        # Initialize git repository
 | 
					 | 
				
			||||||
        git init
 | 
					 | 
				
			||||||
        git remote add origin https://github.com/PatchMon/PatchMon.git
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        # Fetch all branches
 | 
					 | 
				
			||||||
        git fetch origin
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        # Try to determine current version from package.json or default to main
 | 
					 | 
				
			||||||
        local current_branch="main"
 | 
					 | 
				
			||||||
        if [ -f "$instance_dir/backend/package.json" ]; then
 | 
					 | 
				
			||||||
            local pkg_version=$(grep '"version"' "$instance_dir/backend/package.json" | head -1 | sed 's/.*"version": "\(.*\)".*/\1/')
 | 
					 | 
				
			||||||
            if [ -n "$pkg_version" ]; then
 | 
					 | 
				
			||||||
                # Check if there's a release branch for this version
 | 
					 | 
				
			||||||
                if git ls-remote --heads origin | grep -q "release/$(echo $pkg_version | sed 's/\./-/g')"; then
 | 
					 | 
				
			||||||
                    current_branch="release/$(echo $pkg_version | sed 's/\./-/g')"
 | 
					 | 
				
			||||||
                fi
 | 
					 | 
				
			||||||
            fi
 | 
					 | 
				
			||||||
        fi
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        # Reset to the determined branch
 | 
					 | 
				
			||||||
        git reset --hard "origin/$current_branch"
 | 
					 | 
				
			||||||
        git checkout -B "$current_branch" "origin/$current_branch"
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        print_success "Repository initialized successfully"
 | 
					 | 
				
			||||||
    fi
 | 
					    fi
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    # Add git safe.directory to avoid ownership issues when running as root
 | 
					    # Add git safe.directory to avoid ownership issues when running as root
 | 
				
			||||||
@@ -3035,8 +2931,6 @@ update_installation() {
 | 
				
			|||||||
    
 | 
					    
 | 
				
			||||||
    # Load existing .env to get database credentials
 | 
					    # Load existing .env to get database credentials
 | 
				
			||||||
    if [ -f "$instance_dir/backend/.env" ]; then
 | 
					    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"
 | 
					        source "$instance_dir/backend/.env"
 | 
				
			||||||
        print_status "Loaded existing configuration"
 | 
					        print_status "Loaded existing configuration"
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
@@ -3104,16 +2998,11 @@ update_installation() {
 | 
				
			|||||||
    
 | 
					    
 | 
				
			||||||
    # Clean up any untracked files that might conflict with incoming changes
 | 
					    # Clean up any untracked files that might conflict with incoming changes
 | 
				
			||||||
    print_info "Cleaning up untracked files to prevent merge conflicts..."
 | 
					    print_info "Cleaning up untracked files to prevent merge conflicts..."
 | 
				
			||||||
    git clean -fd 2>/dev/null || true
 | 
					    git clean -fd
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    # Reset any local changes to ensure clean state
 | 
					    # Reset any local changes to ensure clean state
 | 
				
			||||||
    # Check if HEAD exists before trying to reset
 | 
					 | 
				
			||||||
    print_info "Resetting local changes to ensure clean state..."
 | 
					    print_info "Resetting local changes to ensure clean state..."
 | 
				
			||||||
    if git rev-parse --verify HEAD >/dev/null 2>&1; then
 | 
					    git reset --hard HEAD
 | 
				
			||||||
        git reset --hard HEAD
 | 
					 | 
				
			||||||
    else
 | 
					 | 
				
			||||||
        print_warning "HEAD not found, skipping reset (fresh repository or detached state)"
 | 
					 | 
				
			||||||
    fi
 | 
					 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    # Fetch latest changes
 | 
					    # Fetch latest changes
 | 
				
			||||||
    git fetch origin
 | 
					    git fetch origin
 | 
				
			||||||
@@ -3137,12 +3026,6 @@ update_installation() {
 | 
				
			|||||||
    print_info "Building frontend..."
 | 
					    print_info "Building frontend..."
 | 
				
			||||||
    npm run build
 | 
					    npm run build
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    # Make agent binaries executable
 | 
					 | 
				
			||||||
    if [ -d "$instance_dir/agents" ]; then
 | 
					 | 
				
			||||||
        chmod +x "$instance_dir/agents/patchmon-agent-linux-"* 2>/dev/null || true
 | 
					 | 
				
			||||||
        print_status "Agent binaries made executable"
 | 
					 | 
				
			||||||
    fi
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Run database migrations with self-healing
 | 
					    # Run database migrations with self-healing
 | 
				
			||||||
    print_info "Running database migrations..."
 | 
					    print_info "Running database migrations..."
 | 
				
			||||||
    cd "$instance_dir/backend"
 | 
					    cd "$instance_dir/backend"
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user