From 7a8e9d95a0193af0157724d49fef55f70a22a52d Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 07:03:10 +0100 Subject: [PATCH 01/51] Added a global search bar --- backend/src/routes/searchRoutes.js | 247 +++++++++++++ backend/src/server.js | 2 + frontend/src/components/GlobalSearch.jsx | 428 +++++++++++++++++++++++ frontend/src/components/Layout.jsx | 13 +- frontend/src/utils/api.js | 5 + 5 files changed, 692 insertions(+), 3 deletions(-) create mode 100644 backend/src/routes/searchRoutes.js create mode 100644 frontend/src/components/GlobalSearch.jsx diff --git a/backend/src/routes/searchRoutes.js b/backend/src/routes/searchRoutes.js new file mode 100644 index 0000000..077f780 --- /dev/null +++ b/backend/src/routes/searchRoutes.js @@ -0,0 +1,247 @@ +const express = require("express"); +const router = express.Router(); +const { createPrismaClient } = require("../config/database"); +const { authenticateToken } = require("../middleware/auth"); + +const prisma = createPrismaClient(); + +/** + * Global search endpoint + * Searches across hosts, packages, repositories, and users + * Returns categorized results + */ +router.get("/", authenticateToken, async (req, res) => { + try { + const { q } = req.query; + + if (!q || q.trim().length === 0) { + return res.json({ + hosts: [], + packages: [], + repositories: [], + users: [], + }); + } + + const searchTerm = q.trim(); + + // Prepare results object + const results = { + hosts: [], + packages: [], + repositories: [], + users: [], + }; + + // Get user permissions from database + let userPermissions = null; + try { + userPermissions = await prisma.role_permissions.findUnique({ + where: { role: req.user.role }, + }); + + // If no specific permissions found, default to admin permissions + if (!userPermissions) { + console.warn( + `No permissions found for role: ${req.user.role}, defaulting to admin access`, + ); + userPermissions = { + can_view_hosts: true, + can_view_packages: true, + can_view_users: true, + }; + } + } catch (permError) { + console.error("Error fetching permissions:", permError); + // Default to restrictive permissions on error + userPermissions = { + can_view_hosts: false, + can_view_packages: false, + can_view_users: false, + }; + } + + // Search hosts if user has permission + if (userPermissions.can_view_hosts) { + try { + const hosts = await prisma.hosts.findMany({ + where: { + OR: [ + { hostname: { contains: searchTerm, mode: "insensitive" } }, + { friendly_name: { contains: searchTerm, mode: "insensitive" } }, + { ip: { contains: searchTerm, mode: "insensitive" } }, + ], + }, + select: { + id: true, + hostname: true, + friendly_name: true, + ip: true, + os_type: true, + os_version: true, + status: true, + last_update: true, + }, + take: 10, // Limit results + orderBy: { + last_update: "desc", + }, + }); + + results.hosts = hosts.map((host) => ({ + id: host.id, + hostname: host.hostname, + friendly_name: host.friendly_name, + ip: host.ip, + os_type: host.os_type, + os_version: host.os_version, + status: host.status, + last_update: host.last_update, + type: "host", + })); + } catch (error) { + console.error("Error searching hosts:", error); + } + } + + // Search packages if user has permission + if (userPermissions.can_view_packages) { + try { + const packages = await prisma.packages.findMany({ + where: { + name: { contains: searchTerm, mode: "insensitive" }, + }, + select: { + id: true, + name: true, + description: true, + category: true, + latest_version: true, + _count: { + select: { + host_packages: true, + }, + }, + }, + take: 10, + orderBy: { + name: "asc", + }, + }); + + results.packages = packages.map((pkg) => ({ + id: pkg.id, + name: pkg.name, + description: pkg.description, + category: pkg.category, + latest_version: pkg.latest_version, + host_count: pkg._count.host_packages, + type: "package", + })); + } catch (error) { + console.error("Error searching packages:", error); + } + } + + // Search repositories if user has permission (usually same as hosts) + if (userPermissions.can_view_hosts) { + try { + const repositories = await prisma.repositories.findMany({ + where: { + OR: [ + { name: { contains: searchTerm, mode: "insensitive" } }, + { url: { contains: searchTerm, mode: "insensitive" } }, + { description: { contains: searchTerm, mode: "insensitive" } }, + ], + }, + select: { + id: true, + name: true, + url: true, + distribution: true, + repo_type: true, + is_active: true, + description: true, + _count: { + select: { + host_repositories: true, + }, + }, + }, + take: 10, + orderBy: { + name: "asc", + }, + }); + + results.repositories = repositories.map((repo) => ({ + id: repo.id, + name: repo.name, + url: repo.url, + distribution: repo.distribution, + repo_type: repo.repo_type, + is_active: repo.is_active, + description: repo.description, + host_count: repo._count.host_repositories, + type: "repository", + })); + } catch (error) { + console.error("Error searching repositories:", error); + } + } + + // Search users if user has permission + if (userPermissions.can_view_users) { + try { + const users = await prisma.users.findMany({ + where: { + OR: [ + { username: { contains: searchTerm, mode: "insensitive" } }, + { email: { contains: searchTerm, mode: "insensitive" } }, + { first_name: { contains: searchTerm, mode: "insensitive" } }, + { last_name: { contains: searchTerm, mode: "insensitive" } }, + ], + }, + select: { + id: true, + username: true, + email: true, + first_name: true, + last_name: true, + role: true, + is_active: true, + last_login: true, + }, + take: 10, + orderBy: { + username: "asc", + }, + }); + + results.users = users.map((user) => ({ + id: user.id, + username: user.username, + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + role: user.role, + is_active: user.is_active, + last_login: user.last_login, + type: "user", + })); + } catch (error) { + console.error("Error searching users:", error); + } + } + + res.json(results); + } catch (error) { + console.error("Global search error:", error); + res.status(500).json({ + error: "Failed to perform search", + message: error.message, + }); + } +}); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index 78fc180..d4e529e 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -24,6 +24,7 @@ const { const repositoryRoutes = require("./routes/repositoryRoutes"); const versionRoutes = require("./routes/versionRoutes"); const tfaRoutes = require("./routes/tfaRoutes"); +const searchRoutes = require("./routes/searchRoutes"); const updateScheduler = require("./services/updateScheduler"); const { initSettings } = require("./services/settingsService"); const { cleanup_expired_sessions } = require("./utils/session_manager"); @@ -378,6 +379,7 @@ app.use(`/api/${apiVersion}/dashboard-preferences`, dashboardPreferencesRoutes); app.use(`/api/${apiVersion}/repositories`, repositoryRoutes); app.use(`/api/${apiVersion}/version`, versionRoutes); app.use(`/api/${apiVersion}/tfa`, tfaRoutes); +app.use(`/api/${apiVersion}/search`, searchRoutes); // Error handling middleware app.use((err, _req, res, _next) => { diff --git a/frontend/src/components/GlobalSearch.jsx b/frontend/src/components/GlobalSearch.jsx new file mode 100644 index 0000000..27b515b --- /dev/null +++ b/frontend/src/components/GlobalSearch.jsx @@ -0,0 +1,428 @@ +import { GitBranch, Package, Search, Server, User, X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { searchAPI } from "../utils/api"; + +const GlobalSearch = () => { + const [query, setQuery] = useState(""); + const [results, setResults] = useState(null); + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const searchRef = useRef(null); + const inputRef = useRef(null); + const navigate = useNavigate(); + + // Debounce search + const debounceTimerRef = useRef(null); + + const performSearch = useCallback(async (searchQuery) => { + if (!searchQuery || searchQuery.trim().length === 0) { + setResults(null); + setIsOpen(false); + return; + } + + setIsLoading(true); + try { + const response = await searchAPI.global(searchQuery); + setResults(response.data); + setIsOpen(true); + setSelectedIndex(-1); + } catch (error) { + console.error("Search error:", error); + setResults(null); + } finally { + setIsLoading(false); + } + }, []); + + const handleInputChange = (e) => { + const value = e.target.value; + setQuery(value); + + // Clear previous timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + // Set new timer + debounceTimerRef.current = setTimeout(() => { + performSearch(value); + }, 300); + }; + + const handleClear = () => { + // Clear debounce timer to prevent any pending searches + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + setQuery(""); + setResults(null); + setIsOpen(false); + setSelectedIndex(-1); + inputRef.current?.focus(); + }; + + const handleResultClick = (result) => { + // Navigate based on result type + switch (result.type) { + case "host": + navigate(`/hosts/${result.id}`); + break; + case "package": + navigate(`/packages/${result.id}`); + break; + case "repository": + navigate(`/repositories/${result.id}`); + break; + case "user": + // Users don't have detail pages, so navigate to settings + navigate("/settings/users"); + break; + default: + break; + } + + // Close dropdown and clear + handleClear(); + }; + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event) => { + if (searchRef.current && !searchRef.current.contains(event.target)) { + setIsOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + // Keyboard navigation + const flattenedResults = []; + if (results) { + if (results.hosts?.length > 0) { + flattenedResults.push({ type: "header", label: "Hosts" }); + flattenedResults.push(...results.hosts); + } + if (results.packages?.length > 0) { + flattenedResults.push({ type: "header", label: "Packages" }); + flattenedResults.push(...results.packages); + } + if (results.repositories?.length > 0) { + flattenedResults.push({ type: "header", label: "Repositories" }); + flattenedResults.push(...results.repositories); + } + if (results.users?.length > 0) { + flattenedResults.push({ type: "header", label: "Users" }); + flattenedResults.push(...results.users); + } + } + + const navigableResults = flattenedResults.filter((r) => r.type !== "header"); + + const handleKeyDown = (e) => { + if (!isOpen || !results) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setSelectedIndex((prev) => + prev < navigableResults.length - 1 ? prev + 1 : prev, + ); + break; + case "ArrowUp": + e.preventDefault(); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1)); + break; + case "Enter": + e.preventDefault(); + if (selectedIndex >= 0 && navigableResults[selectedIndex]) { + handleResultClick(navigableResults[selectedIndex]); + } + break; + case "Escape": + e.preventDefault(); + setIsOpen(false); + setSelectedIndex(-1); + break; + default: + break; + } + }; + + // Get icon for result type + const getResultIcon = (type) => { + switch (type) { + case "host": + return ; + case "package": + return ; + case "repository": + return ; + case "user": + return ; + default: + return null; + } + }; + + // Get display text for result + const getResultDisplay = (result) => { + switch (result.type) { + case "host": + return { + primary: result.friendly_name || result.hostname, + secondary: result.ip || result.hostname, + }; + case "package": + return { + primary: result.name, + secondary: result.description || result.category, + }; + case "repository": + return { + primary: result.name, + secondary: result.distribution, + }; + case "user": + return { + primary: result.username, + secondary: result.email, + }; + default: + return { primary: "", secondary: "" }; + } + }; + + const hasResults = + results && + (results.hosts?.length > 0 || + results.packages?.length > 0 || + results.repositories?.length > 0 || + results.users?.length > 0); + + return ( +
+
+
+ +
+ { + if (query && results) setIsOpen(true); + }} + /> + {query && ( + + )} +
+ + {/* Dropdown Results */} + {isOpen && ( +
+ {isLoading ? ( +
+ Searching... +
+ ) : hasResults ? ( +
+ {/* Hosts */} + {results.hosts?.length > 0 && ( +
+
+ Hosts +
+ {results.hosts.map((host, idx) => { + const display = getResultDisplay(host); + const globalIdx = navigableResults.findIndex( + (r) => r.id === host.id && r.type === "host", + ); + return ( + + ); + })} +
+ )} + + {/* Packages */} + {results.packages?.length > 0 && ( +
+
+ Packages +
+ {results.packages.map((pkg, idx) => { + const display = getResultDisplay(pkg); + const globalIdx = navigableResults.findIndex( + (r) => r.id === pkg.id && r.type === "package", + ); + return ( + + ); + })} +
+ )} + + {/* Repositories */} + {results.repositories?.length > 0 && ( +
+
+ Repositories +
+ {results.repositories.map((repo, idx) => { + const display = getResultDisplay(repo); + const globalIdx = navigableResults.findIndex( + (r) => r.id === repo.id && r.type === "repository", + ); + return ( + + ); + })} +
+ )} + + {/* Users */} + {results.users?.length > 0 && ( +
+
+ Users +
+ {results.users.map((user, idx) => { + const display = getResultDisplay(user); + const globalIdx = navigableResults.findIndex( + (r) => r.id === user.id && r.type === "user", + ); + return ( + + ); + })} +
+ )} +
+ ) : query.trim() ? ( +
+ No results found for "{query}" +
+ ) : null} +
+ )} +
+ ); +}; + +export default GlobalSearch; diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index a2a096e..4bb70a9 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -29,6 +29,7 @@ import { Link, useLocation, useNavigate } from "react-router-dom"; import { useAuth } from "../contexts/AuthContext"; import { useUpdateNotification } from "../contexts/UpdateNotificationContext"; import { dashboardAPI, versionAPI } from "../utils/api"; +import GlobalSearch from "./GlobalSearch"; import UpgradeNotificationIcon from "./UpgradeNotificationIcon"; const Layout = ({ children }) => { @@ -866,12 +867,18 @@ const Layout = ({ children }) => {
-
-

+
+

{getPageTitle()}

-
+ + {/* Global Search Bar */} +
+ +
+ +
{/* External Links */}
{ return `${seconds} second${seconds > 1 ? "s" : ""} ago`; }; +// Search API +export const searchAPI = { + global: (query) => api.get("/search", { params: { q: query } }), +}; + export default api; From b99f4aad4e89c1cc2208eedc684afb8ad00ff890 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 07:50:10 +0100 Subject: [PATCH 02/51] feat: Add Proxmox LXC auto-enrollment integration - Add auto_enrollment_tokens table with rate limiting and IP whitelisting - Create backend API routes for token management and enrollment - Build frontend UI for token creation and management - Add one-liner curl command for easy Proxmox deployment - Include Proxmox LXC discovery and enrollment script - Support future integrations with /proxmox-lxc endpoint pattern - Add comprehensive documentation Security features: - Hashed token secrets - Per-day rate limits - IP whitelist support - Token expiration - Separate enrollment vs host API credentials --- README.md | 1 + agents/proxmox_auto_enroll.sh | 242 +++++++ backend/prisma/schema.prisma | 72 +- backend/src/routes/autoEnrollmentRoutes.js | 724 +++++++++++++++++++ backend/src/server.js | 6 + frontend/src/pages/settings/Integrations.jsx | 679 ++++++++++++++++- 6 files changed, 1680 insertions(+), 44 deletions(-) create mode 100755 agents/proxmox_auto_enroll.sh create mode 100644 backend/src/routes/autoEnrollmentRoutes.js diff --git a/README.md b/README.md index ae7e47b..94dab7a 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ PatchMon provides centralized patch management across diverse server environment ### API & Integrations - REST API under `/api/v1` with JWT auth +- **Proxmox LXC Auto-Enrollment** - Automatically discover and enroll LXC containers from Proxmox hosts ([Documentation](PROXMOX_AUTO_ENROLLMENT.md)) ### Security - Rate limiting for general, auth, and agent endpoints diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh new file mode 100755 index 0000000..79142f5 --- /dev/null +++ b/agents/proxmox_auto_enroll.sh @@ -0,0 +1,242 @@ +#!/bin/bash + +# ============================================================================= +# PatchMon Proxmox LXC Auto-Enrollment Script +# ============================================================================= +# This script discovers LXC containers on a Proxmox host and automatically +# enrolls them into PatchMon for patch management. +# +# Usage: +# 1. Set environment variables or edit configuration below +# 2. Run: bash proxmox_auto_enroll.sh +# +# Requirements: +# - Must run on Proxmox host (requires 'pct' command) +# - Auto-enrollment token from PatchMon +# - Network access to PatchMon server +# ============================================================================= + +set -e + +# ===== CONFIGURATION ===== +PATCHMON_URL="${PATCHMON_URL:-https://patchmon.example.com}" +AUTO_ENROLLMENT_KEY="${AUTO_ENROLLMENT_KEY:-}" +AUTO_ENROLLMENT_SECRET="${AUTO_ENROLLMENT_SECRET:-}" +CURL_FLAGS="${CURL_FLAGS:--s}" +DRY_RUN="${DRY_RUN:-false}" +HOST_PREFIX="${HOST_PREFIX:-proxmox-}" +SKIP_STOPPED="${SKIP_STOPPED:-true}" +PARALLEL_INSTALL="${PARALLEL_INSTALL:-false}" +MAX_PARALLEL="${MAX_PARALLEL:-5}" + +# ===== COLOR OUTPUT ===== +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# ===== LOGGING FUNCTIONS ===== +info() { echo -e "${GREEN}[INFO]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } +debug() { [[ "${DEBUG:-false}" == "true" ]] && echo -e "${BLUE}[DEBUG]${NC} $1"; } + +# ===== BANNER ===== +cat << "EOF" +╔═══════════════════════════════════════════════════════════════╗ +║ ║ +║ ____ _ _ __ __ ║ +║ | _ \ __ _| |_ ___| |__ | \/ | ___ _ __ ║ +║ | |_) / _` | __/ __| '_ \| |\/| |/ _ \| '_ \ ║ +║ | __/ (_| | || (__| | | | | | | (_) | | | | ║ +║ |_| \__,_|\__\___|_| |_|_| |_|\___/|_| |_| ║ +║ ║ +║ Proxmox LXC Auto-Enrollment Script ║ +║ ║ +╚═══════════════════════════════════════════════════════════════╝ +EOF +echo "" + +# ===== VALIDATION ===== +info "Validating configuration..." + +if [[ -z "$AUTO_ENROLLMENT_KEY" ]] || [[ -z "$AUTO_ENROLLMENT_SECRET" ]]; then + error "AUTO_ENROLLMENT_KEY and AUTO_ENROLLMENT_SECRET must be set" +fi + +if [[ -z "$PATCHMON_URL" ]]; then + error "PATCHMON_URL must be set" +fi + +# Check if running on Proxmox +if ! command -v pct &> /dev/null; then + error "This script must run on a Proxmox host (pct command not found)" +fi + +# Check for required commands +for cmd in curl jq; do + if ! command -v $cmd &> /dev/null; then + error "Required command '$cmd' not found. Please install it first." + fi +done + +info "Configuration validated successfully" +info "PatchMon Server: $PATCHMON_URL" +info "Dry Run Mode: $DRY_RUN" +info "Skip Stopped Containers: $SKIP_STOPPED" +echo "" + +# ===== DISCOVER LXC CONTAINERS ===== +info "Discovering LXC containers..." +lxc_list=$(pct list | tail -n +2) # Skip header + +if [[ -z "$lxc_list" ]]; then + warn "No LXC containers found on this Proxmox host" + exit 0 +fi + +# Count containers +total_containers=$(echo "$lxc_list" | wc -l) +info "Found $total_containers LXC container(s)" +echo "" + +# ===== STATISTICS ===== +enrolled_count=0 +skipped_count=0 +failed_count=0 + +# ===== PROCESS CONTAINERS ===== +while IFS= read -r line; do + vmid=$(echo "$line" | awk '{print $1}') + status=$(echo "$line" | awk '{print $2}') + name=$(echo "$line" | awk '{print $3}') + + info "Processing LXC $vmid: $name (status: $status)" + + # Skip stopped containers if configured + if [[ "$status" != "running" ]] && [[ "$SKIP_STOPPED" == "true" ]]; then + warn " Skipping $name - container not running" + ((skipped_count++)) + echo "" + continue + fi + + # Check if container is stopped + if [[ "$status" != "running" ]]; then + warn " Container $name is stopped - cannot gather info or install agent" + ((skipped_count++)) + echo "" + continue + fi + + # Get container details + debug " Gathering container information..." + hostname=$(pct exec "$vmid" -- hostname 2>/dev/null || echo "$name") + ip_address=$(pct exec "$vmid" -- hostname -I 2>/dev/null | awk '{print $1}' || echo "unknown") + os_info=$(pct exec "$vmid" -- cat /etc/os-release 2>/dev/null | grep "^PRETTY_NAME=" | cut -d'"' -f2 || echo "unknown") + + friendly_name="${HOST_PREFIX}${hostname}" + + info " Hostname: $hostname" + info " IP Address: $ip_address" + info " OS: $os_info" + + if [[ "$DRY_RUN" == "true" ]]; then + info " [DRY RUN] Would enroll: $friendly_name" + ((enrolled_count++)) + echo "" + continue + fi + + # Call PatchMon auto-enrollment API + info " Enrolling $friendly_name in PatchMon..." + + response=$(curl $CURL_FLAGS -X POST \ + -H "X-Auto-Enrollment-Key: $AUTO_ENROLLMENT_KEY" \ + -H "X-Auto-Enrollment-Secret: $AUTO_ENROLLMENT_SECRET" \ + -H "Content-Type: application/json" \ + -d "{ + \"friendly_name\": \"$friendly_name\", + \"metadata\": { + \"vmid\": \"$vmid\", + \"proxmox_node\": \"$(hostname)\", + \"ip_address\": \"$ip_address\", + \"os_info\": \"$os_info\" + } + }" \ + "$PATCHMON_URL/api/v1/auto-enrollment/enroll" \ + -w "\n%{http_code}" 2>&1) + + http_code=$(echo "$response" | tail -n 1) + body=$(echo "$response" | sed '$d') + + if [[ "$http_code" == "201" ]]; then + api_id=$(echo "$body" | jq -r '.host.api_id' 2>/dev/null || echo "") + api_key=$(echo "$body" | jq -r '.host.api_key' 2>/dev/null || echo "") + + if [[ -z "$api_id" ]] || [[ -z "$api_key" ]]; then + error " Failed to parse API credentials from response" + fi + + info " ✓ Host enrolled successfully: $api_id" + + # Install PatchMon agent in container + info " Installing PatchMon agent..." + + install_output=$(pct exec "$vmid" -- bash -c "curl $CURL_FLAGS \ + -H 'X-API-ID: $api_id' \ + -H 'X-API-KEY: $api_key' \ + '$PATCHMON_URL/api/v1/hosts/install' | bash" 2>&1) + + if [[ $? -eq 0 ]]; then + info " ✓ Agent installed successfully in $friendly_name" + ((enrolled_count++)) + else + error " ✗ Failed to install agent in $friendly_name" + debug " Install output: $install_output" + ((failed_count++)) + fi + + elif [[ "$http_code" == "409" ]]; then + warn " ⊘ Host $friendly_name already enrolled - skipping" + ((skipped_count++)) + elif [[ "$http_code" == "429" ]]; then + error " ✗ Rate limit exceeded - maximum hosts per day reached" + ((failed_count++)) + else + error " ✗ Failed to enroll $friendly_name - HTTP $http_code" + debug " Response: $body" + ((failed_count++)) + fi + + echo "" + sleep 1 # Rate limiting between containers + +done <<< "$lxc_list" + +# ===== SUMMARY ===== +echo "" +echo "╔═══════════════════════════════════════════════════════════════╗" +echo "║ ENROLLMENT SUMMARY ║" +echo "╚═══════════════════════════════════════════════════════════════╝" +echo "" +info "Total Containers Found: $total_containers" +info "Successfully Enrolled: $enrolled_count" +info "Skipped: $skipped_count" +info "Failed: $failed_count" +echo "" + +if [[ "$DRY_RUN" == "true" ]]; then + warn "This was a DRY RUN - no actual changes were made" + warn "Set DRY_RUN=false to perform actual enrollment" +fi + +if [[ $failed_count -gt 0 ]]; then + warn "Some containers failed to enroll. Check the logs above for details." + exit 1 +fi + +info "Auto-enrollment complete! ✓" +exit 0 + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 160aa1c..5db7dcb 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -21,13 +21,14 @@ model dashboard_preferences { } model host_groups { - id String @id - name String @unique - description String? - color String? @default("#3B82F6") - created_at DateTime @default(now()) - updated_at DateTime - hosts hosts[] + id String @id + name String @unique + description String? + color String? @default("#3B82F6") + created_at DateTime @default(now()) + updated_at DateTime + hosts hosts[] + auto_enrollment_tokens auto_enrollment_tokens[] } model host_packages { @@ -172,22 +173,23 @@ model update_history { } model users { - id String @id - username String @unique - email String @unique - password_hash String - role String @default("admin") - is_active Boolean @default(true) - last_login DateTime? - created_at DateTime @default(now()) - updated_at DateTime - tfa_backup_codes String? - tfa_enabled Boolean @default(false) - tfa_secret String? - first_name String? - last_name String? - dashboard_preferences dashboard_preferences[] - user_sessions user_sessions[] + id String @id + username String @unique + email String @unique + password_hash String + role String @default("admin") + is_active Boolean @default(true) + last_login DateTime? + created_at DateTime @default(now()) + updated_at DateTime + tfa_backup_codes String? + tfa_enabled Boolean @default(false) + tfa_secret String? + first_name String? + last_name String? + dashboard_preferences dashboard_preferences[] + user_sessions user_sessions[] + auto_enrollment_tokens auto_enrollment_tokens[] } model user_sessions { @@ -207,3 +209,27 @@ model user_sessions { @@index([refresh_token]) @@index([expires_at]) } + +model auto_enrollment_tokens { + id String @id + token_name String + token_key String @unique + token_secret String + created_by_user_id String? + is_active Boolean @default(true) + allowed_ip_ranges String[] + max_hosts_per_day Int @default(100) + hosts_created_today Int @default(0) + last_reset_date DateTime @default(now()) @db.Date + default_host_group_id String? + created_at DateTime @default(now()) + updated_at DateTime + last_used_at DateTime? + expires_at DateTime? + metadata Json? + users users? @relation(fields: [created_by_user_id], references: [id], onDelete: SetNull) + host_groups host_groups? @relation(fields: [default_host_group_id], references: [id], onDelete: SetNull) + + @@index([token_key]) + @@index([is_active]) +} diff --git a/backend/src/routes/autoEnrollmentRoutes.js b/backend/src/routes/autoEnrollmentRoutes.js new file mode 100644 index 0000000..e475d56 --- /dev/null +++ b/backend/src/routes/autoEnrollmentRoutes.js @@ -0,0 +1,724 @@ +const express = require("express"); +const { PrismaClient } = require("@prisma/client"); +const crypto = require("node:crypto"); +const bcrypt = require("bcryptjs"); +const { body, validationResult } = require("express-validator"); +const { authenticateToken } = require("../middleware/auth"); +const { requireManageSettings } = require("../middleware/permissions"); +const { v4: uuidv4 } = require("uuid"); + +const router = express.Router(); +const prisma = new PrismaClient(); + +// Generate auto-enrollment token credentials +const generate_auto_enrollment_token = () => { + const token_key = `patchmon_ae_${crypto.randomBytes(16).toString("hex")}`; + const token_secret = crypto.randomBytes(48).toString("hex"); + return { token_key, token_secret }; +}; + +// Middleware to validate auto-enrollment token +const validate_auto_enrollment_token = async (req, res, next) => { + try { + const token_key = req.headers["x-auto-enrollment-key"]; + const token_secret = req.headers["x-auto-enrollment-secret"]; + + if (!token_key || !token_secret) { + return res + .status(401) + .json({ error: "Auto-enrollment credentials required" }); + } + + // Find token + const token = await prisma.auto_enrollment_tokens.findUnique({ + where: { token_key: token_key }, + }); + + if (!token || !token.is_active) { + return res.status(401).json({ error: "Invalid or inactive token" }); + } + + // Verify secret (hashed) + const is_valid = await bcrypt.compare(token_secret, token.token_secret); + if (!is_valid) { + return res.status(401).json({ error: "Invalid token secret" }); + } + + // Check expiration + if (token.expires_at && new Date() > new Date(token.expires_at)) { + return res.status(401).json({ error: "Token expired" }); + } + + // Check IP whitelist if configured + if (token.allowed_ip_ranges && token.allowed_ip_ranges.length > 0) { + const client_ip = req.ip || req.connection.remoteAddress; + // Basic IP check - can be enhanced with CIDR matching + const ip_allowed = token.allowed_ip_ranges.some((allowed_ip) => { + return client_ip.includes(allowed_ip); + }); + + if (!ip_allowed) { + console.warn( + `Auto-enrollment attempt from unauthorized IP: ${client_ip}`, + ); + return res + .status(403) + .json({ error: "IP address not authorized for this token" }); + } + } + + // Check rate limit (hosts per day) + const today = new Date().toISOString().split("T")[0]; + const token_reset_date = token.last_reset_date.toISOString().split("T")[0]; + + if (token_reset_date !== today) { + // Reset daily counter + await prisma.auto_enrollment_tokens.update({ + where: { id: token.id }, + data: { + hosts_created_today: 0, + last_reset_date: new Date(), + updated_at: new Date(), + }, + }); + token.hosts_created_today = 0; + } + + if (token.hosts_created_today >= token.max_hosts_per_day) { + return res.status(429).json({ + error: "Rate limit exceeded", + message: `Maximum ${token.max_hosts_per_day} hosts per day allowed for this token`, + }); + } + + req.auto_enrollment_token = token; + next(); + } catch (error) { + console.error("Auto-enrollment token validation error:", error); + res.status(500).json({ error: "Token validation failed" }); + } +}; + +// ========== ADMIN ENDPOINTS (Manage Tokens) ========== + +// Create auto-enrollment token +router.post( + "/tokens", + authenticateToken, + requireManageSettings, + [ + body("token_name") + .isLength({ min: 1, max: 255 }) + .withMessage("Token name is required (max 255 characters)"), + body("allowed_ip_ranges") + .optional() + .isArray() + .withMessage("Allowed IP ranges must be an array"), + body("max_hosts_per_day") + .optional() + .isInt({ min: 1, max: 1000 }) + .withMessage("Max hosts per day must be between 1 and 1000"), + body("default_host_group_id") + .optional({ nullable: true, checkFalsy: true }) + .isString(), + body("expires_at") + .optional({ nullable: true, checkFalsy: true }) + .isISO8601() + .withMessage("Invalid date format"), + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { + token_name, + allowed_ip_ranges = [], + max_hosts_per_day = 100, + default_host_group_id, + expires_at, + metadata = {}, + } = req.body; + + // Validate host group if provided + if (default_host_group_id) { + const host_group = await prisma.host_groups.findUnique({ + where: { id: default_host_group_id }, + }); + + if (!host_group) { + return res.status(400).json({ error: "Host group not found" }); + } + } + + const { token_key, token_secret } = generate_auto_enrollment_token(); + const hashed_secret = await bcrypt.hash(token_secret, 10); + + const token = await prisma.auto_enrollment_tokens.create({ + data: { + id: uuidv4(), + token_name, + token_key: token_key, + token_secret: hashed_secret, + created_by_user_id: req.user.id, + allowed_ip_ranges, + max_hosts_per_day, + default_host_group_id: default_host_group_id || null, + expires_at: expires_at ? new Date(expires_at) : null, + metadata: { integration_type: "proxmox-lxc", ...metadata }, + updated_at: new Date(), + }, + include: { + host_groups: { + select: { + id: true, + name: true, + color: true, + }, + }, + users: { + select: { + id: true, + username: true, + first_name: true, + last_name: true, + }, + }, + }, + }); + + // Return unhashed secret ONLY once (like API keys) + res.status(201).json({ + message: "Auto-enrollment token created successfully", + token: { + id: token.id, + token_name: token.token_name, + token_key: token_key, + token_secret: token_secret, // ONLY returned here! + max_hosts_per_day: token.max_hosts_per_day, + default_host_group: token.host_groups, + created_by: token.users, + expires_at: token.expires_at, + }, + warning: "⚠️ Save the token_secret now - it cannot be retrieved later!", + }); + } catch (error) { + console.error("Create auto-enrollment token error:", error); + res.status(500).json({ error: "Failed to create token" }); + } + }, +); + +// List auto-enrollment tokens +router.get( + "/tokens", + authenticateToken, + requireManageSettings, + async (_req, res) => { + try { + const tokens = await prisma.auto_enrollment_tokens.findMany({ + select: { + id: true, + token_name: true, + token_key: true, + is_active: true, + allowed_ip_ranges: true, + max_hosts_per_day: true, + hosts_created_today: true, + last_used_at: true, + expires_at: true, + created_at: true, + default_host_group_id: true, + metadata: true, + host_groups: { + select: { + id: true, + name: true, + color: true, + }, + }, + users: { + select: { + id: true, + username: true, + first_name: true, + last_name: true, + }, + }, + }, + orderBy: { created_at: "desc" }, + }); + + res.json(tokens); + } catch (error) { + console.error("List auto-enrollment tokens error:", error); + res.status(500).json({ error: "Failed to list tokens" }); + } + }, +); + +// Get single token details +router.get( + "/tokens/:tokenId", + authenticateToken, + requireManageSettings, + async (req, res) => { + try { + const { tokenId } = req.params; + + const token = await prisma.auto_enrollment_tokens.findUnique({ + where: { id: tokenId }, + include: { + host_groups: { + select: { + id: true, + name: true, + color: true, + }, + }, + users: { + select: { + id: true, + username: true, + first_name: true, + last_name: true, + }, + }, + }, + }); + + if (!token) { + return res.status(404).json({ error: "Token not found" }); + } + + // Don't include the secret in response + const { token_secret: _secret, ...token_data } = token; + + res.json(token_data); + } catch (error) { + console.error("Get token error:", error); + res.status(500).json({ error: "Failed to get token" }); + } + }, +); + +// Update token (toggle active state, update limits, etc.) +router.patch( + "/tokens/:tokenId", + authenticateToken, + requireManageSettings, + [ + body("is_active").optional().isBoolean(), + body("max_hosts_per_day").optional().isInt({ min: 1, max: 1000 }), + body("allowed_ip_ranges").optional().isArray(), + body("expires_at").optional().isISO8601(), + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { tokenId } = req.params; + const update_data = { updated_at: new Date() }; + + if (req.body.is_active !== undefined) + update_data.is_active = req.body.is_active; + if (req.body.max_hosts_per_day !== undefined) + update_data.max_hosts_per_day = req.body.max_hosts_per_day; + if (req.body.allowed_ip_ranges !== undefined) + update_data.allowed_ip_ranges = req.body.allowed_ip_ranges; + if (req.body.expires_at !== undefined) + update_data.expires_at = new Date(req.body.expires_at); + + const token = await prisma.auto_enrollment_tokens.update({ + where: { id: tokenId }, + data: update_data, + include: { + host_groups: true, + users: { + select: { + username: true, + first_name: true, + last_name: true, + }, + }, + }, + }); + + const { token_secret: _secret, ...token_data } = token; + + res.json({ + message: "Token updated successfully", + token: token_data, + }); + } catch (error) { + console.error("Update token error:", error); + res.status(500).json({ error: "Failed to update token" }); + } + }, +); + +// Delete token +router.delete( + "/tokens/:tokenId", + authenticateToken, + requireManageSettings, + async (req, res) => { + try { + const { tokenId } = req.params; + + const token = await prisma.auto_enrollment_tokens.findUnique({ + where: { id: tokenId }, + }); + + if (!token) { + return res.status(404).json({ error: "Token not found" }); + } + + await prisma.auto_enrollment_tokens.delete({ + where: { id: tokenId }, + }); + + res.json({ + message: "Auto-enrollment token deleted successfully", + deleted_token: { + id: token.id, + token_name: token.token_name, + }, + }); + } catch (error) { + console.error("Delete token error:", error); + res.status(500).json({ error: "Failed to delete token" }); + } + }, +); + +// ========== AUTO-ENROLLMENT ENDPOINTS (Used by Scripts) ========== +// Future integrations can follow this pattern: +// - /proxmox-lxc - Proxmox LXC containers +// - /vmware-esxi - VMware ESXi VMs +// - /docker - Docker containers +// - /kubernetes - Kubernetes pods +// - /aws-ec2 - AWS EC2 instances + +// Serve the Proxmox LXC enrollment script with credentials injected +router.get("/proxmox-lxc", async (req, res) => { + try { + // Get token from query params + const token_key = req.query.token_key; + const token_secret = req.query.token_secret; + + if (!token_key || !token_secret) { + return res + .status(401) + .json({ error: "Token key and secret required as query parameters" }); + } + + // Validate token + const token = await prisma.auto_enrollment_tokens.findUnique({ + where: { token_key: token_key }, + }); + + if (!token || !token.is_active) { + return res.status(401).json({ error: "Invalid or inactive token" }); + } + + // Verify secret + const is_valid = await bcrypt.compare(token_secret, token.token_secret); + if (!is_valid) { + return res.status(401).json({ error: "Invalid token secret" }); + } + + // Check expiration + if (token.expires_at && new Date() > new Date(token.expires_at)) { + return res.status(401).json({ error: "Token expired" }); + } + + const fs = require("node:fs"); + const path = require("node:path"); + + const script_path = path.join( + __dirname, + "../../../agents/proxmox_auto_enroll.sh", + ); + + if (!fs.existsSync(script_path)) { + return res + .status(404) + .json({ error: "Proxmox enrollment script not found" }); + } + + let script = fs.readFileSync(script_path, "utf8"); + + // Convert Windows line endings to Unix line endings + script = script.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + + // Get the configured server URL from settings + let server_url = "http://localhost:3001"; + try { + const settings = await prisma.settings.findFirst(); + if (settings?.server_url) { + server_url = settings.server_url; + } + } catch (settings_error) { + console.warn( + "Could not fetch settings, using default server URL:", + settings_error.message, + ); + } + + // Determine curl flags dynamically from settings + let curl_flags = "-s"; + try { + const settings = await prisma.settings.findFirst(); + if (settings && settings.ignore_ssl_self_signed === true) { + curl_flags = "-sk"; + } + } catch (_) {} + + // Inject the token credentials, server URL, and curl flags into the script + const env_vars = `#!/bin/bash +# PatchMon Auto-Enrollment Configuration (Auto-generated) +export PATCHMON_URL="${server_url}" +export AUTO_ENROLLMENT_KEY="${token.token_key}" +export AUTO_ENROLLMENT_SECRET="${token_secret}" +export CURL_FLAGS="${curl_flags}" + +`; + + // Remove the shebang and configuration section from the original script + script = script.replace(/^#!/, "#"); + + // Remove the configuration section (between # ===== CONFIGURATION ===== and the next # =====) + script = script.replace( + /# ===== CONFIGURATION =====[\s\S]*?(?=# ===== COLOR OUTPUT =====)/, + "", + ); + + script = env_vars + script; + + res.setHeader("Content-Type", "text/plain"); + res.setHeader( + "Content-Disposition", + 'inline; filename="proxmox_auto_enroll.sh"', + ); + res.send(script); + } catch (error) { + console.error("Proxmox script serve error:", error); + res.status(500).json({ error: "Failed to serve enrollment script" }); + } +}); + +// Create host via auto-enrollment +router.post( + "/enroll", + validate_auto_enrollment_token, + [ + body("friendly_name") + .isLength({ min: 1, max: 255 }) + .withMessage("Friendly name is required"), + body("metadata").optional().isObject(), + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { friendly_name } = req.body; + + // Generate host API credentials + const api_id = `patchmon_${crypto.randomBytes(8).toString("hex")}`; + const api_key = crypto.randomBytes(32).toString("hex"); + + // Check if host already exists + const existing_host = await prisma.hosts.findUnique({ + where: { friendly_name }, + }); + + if (existing_host) { + return res.status(409).json({ + error: "Host already exists", + host_id: existing_host.id, + api_id: existing_host.api_id, + message: "This host is already enrolled in PatchMon", + }); + } + + // Create host + const host = await prisma.hosts.create({ + data: { + id: uuidv4(), + friendly_name, + os_type: "unknown", + os_version: "unknown", + api_id: api_id, + api_key: api_key, + host_group_id: req.auto_enrollment_token.default_host_group_id, + status: "pending", + notes: `Auto-enrolled via ${req.auto_enrollment_token.token_name} on ${new Date().toISOString()}`, + updated_at: new Date(), + }, + include: { + host_groups: { + select: { + id: true, + name: true, + color: true, + }, + }, + }, + }); + + // Update token usage stats + await prisma.auto_enrollment_tokens.update({ + where: { id: req.auto_enrollment_token.id }, + data: { + hosts_created_today: { increment: 1 }, + last_used_at: new Date(), + updated_at: new Date(), + }, + }); + + console.log( + `Auto-enrolled host: ${friendly_name} (${host.id}) via token: ${req.auto_enrollment_token.token_name}`, + ); + + res.status(201).json({ + message: "Host enrolled successfully", + host: { + id: host.id, + friendly_name: host.friendly_name, + api_id: api_id, + api_key: api_key, + host_group: host.host_groups, + status: host.status, + }, + }); + } catch (error) { + console.error("Auto-enrollment error:", error); + res.status(500).json({ error: "Failed to enroll host" }); + } + }, +); + +// Bulk enroll multiple hosts at once +router.post( + "/enroll/bulk", + validate_auto_enrollment_token, + [ + body("hosts") + .isArray({ min: 1, max: 50 }) + .withMessage("Hosts array required (max 50)"), + body("hosts.*.friendly_name") + .isLength({ min: 1 }) + .withMessage("Each host needs a friendly_name"), + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { hosts } = req.body; + + // Check rate limit + const remaining_quota = + req.auto_enrollment_token.max_hosts_per_day - + req.auto_enrollment_token.hosts_created_today; + + if (hosts.length > remaining_quota) { + return res.status(429).json({ + error: "Rate limit exceeded", + message: `Only ${remaining_quota} hosts remaining in daily quota`, + }); + } + + const results = { + success: [], + failed: [], + skipped: [], + }; + + for (const host_data of hosts) { + try { + const { friendly_name } = host_data; + + // Check if host already exists + const existing_host = await prisma.hosts.findUnique({ + where: { friendly_name }, + }); + + if (existing_host) { + results.skipped.push({ + friendly_name, + reason: "Already exists", + api_id: existing_host.api_id, + }); + continue; + } + + // Generate credentials + const api_id = `patchmon_${crypto.randomBytes(8).toString("hex")}`; + const api_key = crypto.randomBytes(32).toString("hex"); + + // Create host + const host = await prisma.hosts.create({ + data: { + id: uuidv4(), + friendly_name, + os_type: "unknown", + os_version: "unknown", + api_id: api_id, + api_key: api_key, + host_group_id: req.auto_enrollment_token.default_host_group_id, + status: "pending", + notes: `Auto-enrolled via ${req.auto_enrollment_token.token_name} on ${new Date().toISOString()}`, + updated_at: new Date(), + }, + }); + + results.success.push({ + id: host.id, + friendly_name: host.friendly_name, + api_id: api_id, + api_key: api_key, + }); + } catch (error) { + results.failed.push({ + friendly_name: host_data.friendly_name, + error: error.message, + }); + } + } + + // Update token usage stats + if (results.success.length > 0) { + await prisma.auto_enrollment_tokens.update({ + where: { id: req.auto_enrollment_token.id }, + data: { + hosts_created_today: { increment: results.success.length }, + last_used_at: new Date(), + updated_at: new Date(), + }, + }); + } + + res.status(201).json({ + message: `Bulk enrollment completed: ${results.success.length} succeeded, ${results.failed.length} failed, ${results.skipped.length} skipped`, + results, + }); + } catch (error) { + console.error("Bulk auto-enrollment error:", error); + res.status(500).json({ error: "Failed to bulk enroll hosts" }); + } + }, +); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index d4e529e..6d83c2f 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -25,6 +25,7 @@ const repositoryRoutes = require("./routes/repositoryRoutes"); const versionRoutes = require("./routes/versionRoutes"); const tfaRoutes = require("./routes/tfaRoutes"); const searchRoutes = require("./routes/searchRoutes"); +const autoEnrollmentRoutes = require("./routes/autoEnrollmentRoutes"); const updateScheduler = require("./services/updateScheduler"); const { initSettings } = require("./services/settingsService"); const { cleanup_expired_sessions } = require("./utils/session_manager"); @@ -380,6 +381,11 @@ app.use(`/api/${apiVersion}/repositories`, repositoryRoutes); app.use(`/api/${apiVersion}/version`, versionRoutes); app.use(`/api/${apiVersion}/tfa`, tfaRoutes); app.use(`/api/${apiVersion}/search`, searchRoutes); +app.use( + `/api/${apiVersion}/auto-enrollment`, + authLimiter, + autoEnrollmentRoutes, +); // Error handling middleware app.use((err, _req, res, _next) => { diff --git a/frontend/src/pages/settings/Integrations.jsx b/frontend/src/pages/settings/Integrations.jsx index 200372e..ade539c 100644 --- a/frontend/src/pages/settings/Integrations.jsx +++ b/frontend/src/pages/settings/Integrations.jsx @@ -1,7 +1,164 @@ -import { Plug } from "lucide-react"; +import { + AlertCircle, + CheckCircle, + Copy, + Eye, + EyeOff, + Plus, + Server, + Trash2, + X, +} from "lucide-react"; +import { useEffect, useState } from "react"; import SettingsLayout from "../../components/SettingsLayout"; +import api from "../../utils/api"; const Integrations = () => { + const [tokens, setTokens] = useState([]); + const [host_groups, setHostGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [show_create_modal, setShowCreateModal] = useState(false); + const [new_token, setNewToken] = useState(null); + const [show_secret, setShowSecret] = useState(false); + const [server_url, setServerUrl] = useState(""); + + // Form state + const [form_data, setFormData] = useState({ + token_name: "", + max_hosts_per_day: 100, + default_host_group_id: "", + allowed_ip_ranges: "", + expires_at: "", + }); + + const [copy_success, setCopySuccess] = useState({}); + + // biome-ignore lint/correctness/useExhaustiveDependencies: Only run on mount + useEffect(() => { + load_tokens(); + load_host_groups(); + load_server_url(); + }, []); + + const load_tokens = async () => { + try { + setLoading(true); + const response = await api.get("/auto-enrollment/tokens"); + setTokens(response.data); + } catch (error) { + console.error("Failed to load tokens:", error); + } finally { + setLoading(false); + } + }; + + const load_host_groups = async () => { + try { + const response = await api.get("/host-groups"); + setHostGroups(response.data); + } catch (error) { + console.error("Failed to load host groups:", error); + } + }; + + const load_server_url = async () => { + try { + const response = await api.get("/settings"); + setServerUrl(response.data.server_url || window.location.origin); + } catch (error) { + console.error("Failed to load server URL:", error); + setServerUrl(window.location.origin); + } + }; + + const create_token = async (e) => { + e.preventDefault(); + + try { + const data = { + token_name: form_data.token_name, + max_hosts_per_day: Number.parseInt(form_data.max_hosts_per_day, 10), + allowed_ip_ranges: form_data.allowed_ip_ranges + ? form_data.allowed_ip_ranges.split(",").map((ip) => ip.trim()) + : [], + metadata: { + integration_type: "proxmox-lxc", + }, + }; + + // Only add optional fields if they have values + if (form_data.default_host_group_id) { + data.default_host_group_id = form_data.default_host_group_id; + } + if (form_data.expires_at) { + data.expires_at = form_data.expires_at; + } + + const response = await api.post("/auto-enrollment/tokens", data); + setNewToken(response.data.token); + setShowCreateModal(false); + load_tokens(); + + // Reset form + setFormData({ + token_name: "", + max_hosts_per_day: 100, + default_host_group_id: "", + allowed_ip_ranges: "", + expires_at: "", + }); + } catch (error) { + console.error("Failed to create token:", error); + const error_message = error.response?.data?.errors + ? error.response.data.errors.map((e) => e.msg).join(", ") + : error.response?.data?.error || "Failed to create token"; + alert(error_message); + } + }; + + const delete_token = async (id, name) => { + if ( + !confirm( + `Are you sure you want to delete the token "${name}"? This action cannot be undone.`, + ) + ) { + return; + } + + try { + await api.delete(`/auto-enrollment/tokens/${id}`); + load_tokens(); + } catch (error) { + console.error("Failed to delete token:", error); + alert(error.response?.data?.error || "Failed to delete token"); + } + }; + + const toggle_token_active = async (id, current_status) => { + try { + await api.patch(`/auto-enrollment/tokens/${id}`, { + is_active: !current_status, + }); + load_tokens(); + } catch (error) { + console.error("Failed to toggle token:", error); + alert(error.response?.data?.error || "Failed to toggle token"); + } + }; + + const copy_to_clipboard = (text, key) => { + navigator.clipboard.writeText(text); + setCopySuccess({ ...copy_success, [key]: true }); + setTimeout(() => { + setCopySuccess({ ...copy_success, [key]: false }); + }, 2000); + }; + + const format_date = (date_string) => { + if (!date_string) return "Never"; + return new Date(date_string).toLocaleString(); + }; + return (
@@ -12,36 +169,516 @@ const Integrations = () => { Integrations

- Connect PatchMon to third-party services + Manage auto-enrollment tokens for Proxmox and other integrations +

+
+ +
+ + {/* Proxmox Integration Section */} +
+
+
+ +
+
+

+ Proxmox LXC Auto-Enrollment +

+

+ Automatically discover and enroll LXC containers from Proxmox + hosts +

+
+
+ + {/* Token List */} + {loading ? ( +
+
+
+ ) : tokens.length === 0 ? ( +
+

No auto-enrollment tokens created yet.

+

+ Create a token to enable automatic host enrollment from Proxmox. +

+
+ ) : ( +
+ {tokens.map((token) => ( +
+
+
+
+

+ {token.token_name} +

+ + Proxmox LXC + + {token.is_active ? ( + + Active + + ) : ( + + Inactive + + )} +
+
+
+ + {token.token_key} + + +
+

+ Usage: {token.hosts_created_today}/ + {token.max_hosts_per_day} hosts today +

+ {token.host_groups && ( +

+ Default Group:{" "} + + {token.host_groups.name} + +

+ )} + {token.allowed_ip_ranges?.length > 0 && ( +

+ Allowed IPs: {token.allowed_ip_ranges.join(", ")} +

+ )} +

Created: {format_date(token.created_at)}

+ {token.last_used_at && ( +

Last Used: {format_date(token.last_used_at)}

+ )} + {token.expires_at && ( +

+ Expires: {format_date(token.expires_at)} + {new Date(token.expires_at) < new Date() && ( + + (Expired) + + )} +

+ )} +
+
+
+ + +
+
+
+ ))} +
+ )} +
+ + {/* Documentation Section */} +
+

+ How to Use Auto-Enrollment +

+
    +
  1. Create a new auto-enrollment token using the button above
  2. +
  3. + Copy the one-line installation command shown in the success dialog +
  4. +
  5. SSH into your Proxmox host as root
  6. +
  7. + Paste and run the command - it will automatically discover and + enroll all running LXC containers +
  8. +
  9. View enrolled containers in the Hosts page
  10. +
+
+

+ 💡 Tip: You can run the same command multiple + times safely - already enrolled containers will be automatically + skipped.

+
- {/* Coming Soon Card */} -
-
-
-
- + {/* Create Token Modal */} + {show_create_modal && ( +
+
+
+
+

+ Create Auto-Enrollment Token +

+
+ +
+ + + + + + + + + + +
+ + +
+
-
-

- Integrations Coming Soon -

-

- We are building integrations for Slack, Discord, email, and - webhooks to streamline alerts and workflows. -

-
- - In Development - +
+
+ )} + + {/* New Token Display Modal */} + {new_token && ( +
+
+
+
+
+ +
+
+

+ Token Created Successfully +

+

+ Save these credentials now - the secret will not be shown + again! +

+
+
+ +
+
+ +

+ Important: Store the token secret securely. + You will not be able to view it again after closing this + dialog. +

+
+
+ +
+
+
+ Token Name +
+
+ +
+
+ +
+
+ Token Key +
+
+ + +
+
+ +
+
+ Token Secret +
+
+ + + +
+
+ +
+
+ One-Line Installation Command +
+

+ Run this command on your Proxmox host to download and + execute the enrollment script: +

+
+ + +
+

+ 💡 This command will automatically discover and enroll all + running LXC containers. +

+
+
+ +
+
-
+ )} ); }; From 4e6a9829cf1e15162a4adf64124653bdc9255896 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 08:13:04 +0100 Subject: [PATCH 03/51] chore: Add migration file for auto_enrollment_tokens table --- .../migration.sql | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 backend/prisma/migrations/20251002081229_add_auto_enrollment_tokens/migration.sql diff --git a/backend/prisma/migrations/20251002081229_add_auto_enrollment_tokens/migration.sql b/backend/prisma/migrations/20251002081229_add_auto_enrollment_tokens/migration.sql new file mode 100644 index 0000000..16dea2b --- /dev/null +++ b/backend/prisma/migrations/20251002081229_add_auto_enrollment_tokens/migration.sql @@ -0,0 +1,37 @@ +-- CreateTable +CREATE TABLE "auto_enrollment_tokens" ( + "id" TEXT NOT NULL, + "token_name" TEXT NOT NULL, + "token_key" TEXT NOT NULL, + "token_secret" TEXT NOT NULL, + "created_by_user_id" TEXT, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "allowed_ip_ranges" TEXT[], + "max_hosts_per_day" INTEGER NOT NULL DEFAULT 100, + "hosts_created_today" INTEGER NOT NULL DEFAULT 0, + "last_reset_date" DATE NOT NULL DEFAULT CURRENT_TIMESTAMP, + "default_host_group_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "last_used_at" TIMESTAMP(3), + "expires_at" TIMESTAMP(3), + "metadata" JSONB, + + CONSTRAINT "auto_enrollment_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "auto_enrollment_tokens_token_key_key" ON "auto_enrollment_tokens"("token_key"); + +-- CreateIndex +CREATE INDEX "auto_enrollment_tokens_token_key_idx" ON "auto_enrollment_tokens"("token_key"); + +-- CreateIndex +CREATE INDEX "auto_enrollment_tokens_is_active_idx" ON "auto_enrollment_tokens"("is_active"); + +-- AddForeignKey +ALTER TABLE "auto_enrollment_tokens" ADD CONSTRAINT "auto_enrollment_tokens_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "auto_enrollment_tokens" ADD CONSTRAINT "auto_enrollment_tokens_default_host_group_id_fkey" FOREIGN KEY ("default_host_group_id") REFERENCES "host_groups"("id") ON DELETE SET NULL ON UPDATE CASCADE; + From 9963cfa41788c9a6b8c7a43f4cc460a492ff5399 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 08:28:07 +0100 Subject: [PATCH 04/51] fix: Add timeouts and stdin redirection to prevent pct exec hanging --- agents/proxmox_auto_enroll.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 79142f5..8cd2177 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -132,9 +132,9 @@ while IFS= read -r line; do # Get container details debug " Gathering container information..." - hostname=$(pct exec "$vmid" -- hostname 2>/dev/null || echo "$name") - ip_address=$(pct exec "$vmid" -- hostname -I 2>/dev/null | awk '{print $1}' || echo "unknown") - os_info=$(pct exec "$vmid" -- cat /etc/os-release 2>/dev/null | grep "^PRETTY_NAME=" | cut -d'"' -f2 || echo "unknown") + hostname=$(timeout 5 pct exec "$vmid" -- hostname 2>/dev/null /dev/null /dev/null &1) + '$PATCHMON_URL/api/v1/hosts/install' | bash" 2>&1 Date: Thu, 2 Oct 2025 12:55:52 +0100 Subject: [PATCH 05/51] fix: Detach stdin globally to prevent curl pipe hangs in Proxmox script --- agents/proxmox_auto_enroll.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 8cd2177..a27713f 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -5,6 +5,9 @@ # ============================================================================= # This script discovers LXC containers on a Proxmox host and automatically # enrolls them into PatchMon for patch management. + +# Detach from stdin entirely to prevent hangs when piped from curl +exec Date: Thu, 2 Oct 2025 13:03:50 +0100 Subject: [PATCH 06/51] fix: Remove global exec stdin redirect that breaks curl pipe --- agents/proxmox_auto_enroll.sh | 3 --- 1 file changed, 3 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index a27713f..8cd2177 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -5,9 +5,6 @@ # ============================================================================= # This script discovers LXC containers on a Proxmox host and automatically # enrolls them into PatchMon for patch management. - -# Detach from stdin entirely to prevent hangs when piped from curl -exec Date: Thu, 2 Oct 2025 13:09:13 +0100 Subject: [PATCH 07/51] fix: Close stdin before while loop to prevent hang when piped from curl --- agents/proxmox_auto_enroll.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 8cd2177..5cadf58 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -106,6 +106,9 @@ enrolled_count=0 skipped_count=0 failed_count=0 +# Close stdin to prevent any interference when piped from curl +exec 0<&- + # ===== PROCESS CONTAINERS ===== while IFS= read -r line; do vmid=$(echo "$line" | awk '{print $1}') From e5f3b0ed26db929445811e0e61c69dcefa1355fa Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 13:10:51 +0100 Subject: [PATCH 08/51] debug: Add detailed logging to diagnose where script hangs --- agents/proxmox_auto_enroll.sh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 5cadf58..91d0a8d 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -106,11 +106,10 @@ enrolled_count=0 skipped_count=0 failed_count=0 -# Close stdin to prevent any interference when piped from curl -exec 0<&- - # ===== PROCESS CONTAINERS ===== +info "Starting container processing loop..." while IFS= read -r line; do + info "[DEBUG] Read line from lxc_list" vmid=$(echo "$line" | awk '{print $1}') status=$(echo "$line" | awk '{print $2}') name=$(echo "$line" | awk '{print $3}') From 2abc9b1f8ad920973d9d58cdaf684f8bfcbd8c84 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 13:12:29 +0100 Subject: [PATCH 09/51] debug: Add strict error handling and exit trap to diagnose silent exit --- agents/proxmox_auto_enroll.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 91d0a8d..03aebe1 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -1,4 +1,8 @@ #!/bin/bash +set -euo pipefail # Exit on error, undefined vars, pipe failures + +# Trap to catch any unexpected exits +trap 'echo "[ERROR] Script exited unexpectedly at line $LINENO with exit code $?"' ERR EXIT # ============================================================================= # PatchMon Proxmox LXC Auto-Enrollment Script @@ -101,10 +105,12 @@ total_containers=$(echo "$lxc_list" | wc -l) info "Found $total_containers LXC container(s)" echo "" +info "Initializing statistics..." # ===== STATISTICS ===== enrolled_count=0 skipped_count=0 failed_count=0 +info "Statistics initialized" # ===== PROCESS CONTAINERS ===== info "Starting container processing loop..." From 8c326c8fe2b883a74fa4be9d0ac5bccf56a4b1f6 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 13:14:38 +0100 Subject: [PATCH 10/51] debug: Add version echo at script start for verification --- agents/proxmox_auto_enroll.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 03aebe1..0be2081 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -4,6 +4,9 @@ set -euo pipefail # Exit on error, undefined vars, pipe failures # Trap to catch any unexpected exits trap 'echo "[ERROR] Script exited unexpectedly at line $LINENO with exit code $?"' ERR EXIT +SCRIPT_VERSION="1.0.0-debug.5" +echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" + # ============================================================================= # PatchMon Proxmox LXC Auto-Enrollment Script # ============================================================================= @@ -20,8 +23,6 @@ trap 'echo "[ERROR] Script exited unexpectedly at line $LINENO with exit code $? # - Network access to PatchMon server # ============================================================================= -set -e - # ===== CONFIGURATION ===== PATCHMON_URL="${PATCHMON_URL:-https://patchmon.example.com}" AUTO_ENROLLMENT_KEY="${AUTO_ENROLLMENT_KEY:-}" From 16ea1dc743df6846c60b5da5ac1d1b74928b531a Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 13:15:57 +0100 Subject: [PATCH 11/51] fix: Make logging functions always return 0 to prevent set -e exit --- agents/proxmox_auto_enroll.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 0be2081..8d65e96 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -42,10 +42,11 @@ BLUE='\033[0;34m' NC='\033[0m' # No Color # ===== LOGGING FUNCTIONS ===== -info() { echo -e "${GREEN}[INFO]${NC} $1"; } -warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +info() { echo -e "${GREEN}[INFO]${NC} $1"; return 0; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; return 0; } error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } -debug() { [[ "${DEBUG:-false}" == "true" ]] && echo -e "${BLUE}[DEBUG]${NC} $1"; } +success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; return 0; } +debug() { [[ "${DEBUG:-false}" == "true" ]] && echo -e "${BLUE}[DEBUG]${NC} $1" || true; return 0; } # ===== BANNER ===== cat << "EOF" From 55c8f74b73d00232581fd3ef153f56890348dfee Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 13:16:09 +0100 Subject: [PATCH 12/51] chore: Bump debug version to 1.0.0-debug.6 --- agents/proxmox_auto_enroll.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 8d65e96..2d6b739 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -4,7 +4,7 @@ set -euo pipefail # Exit on error, undefined vars, pipe failures # Trap to catch any unexpected exits trap 'echo "[ERROR] Script exited unexpectedly at line $LINENO with exit code $?"' ERR EXIT -SCRIPT_VERSION="1.0.0-debug.5" +SCRIPT_VERSION="1.0.0-debug.6" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= From bec09b9457048ce2f0c3f80d45eac9424b209cfa Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 13:37:55 +0100 Subject: [PATCH 13/51] fix: Download agent installer to file before executing to prevent stdin pipe hang --- agents/proxmox_auto_enroll.sh | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 2d6b739..150518b 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -4,7 +4,7 @@ set -euo pipefail # Exit on error, undefined vars, pipe failures # Trap to catch any unexpected exits trap 'echo "[ERROR] Script exited unexpectedly at line $LINENO with exit code $?"' ERR EXIT -SCRIPT_VERSION="1.0.0-debug.6" +SCRIPT_VERSION="1.0.0-debug.7" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= @@ -194,16 +194,31 @@ while IFS= read -r line; do # Install PatchMon agent in container info " Installing PatchMon agent..." - install_output=$(timeout 120 pct exec "$vmid" -- bash -c "curl $CURL_FLAGS \ - -H 'X-API-ID: $api_id' \ - -H 'X-API-KEY: $api_key' \ - '$PATCHMON_URL/api/v1/hosts/install' | bash" 2>&1 &1 180s) in $friendly_name" + debug " Install output: $install_output" + ((failed_count++)) else - error " ✗ Failed to install agent in $friendly_name" + warn " ✗ Failed to install agent in $friendly_name (exit: $install_exit_code)" debug " Install output: $install_output" ((failed_count++)) fi From dc68afcb87839e47f9b42f7692ff1183f37b3e1a Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 13:39:28 +0100 Subject: [PATCH 14/51] fix: Prevent set -e exit on agent install failure and show output --- agents/proxmox_auto_enroll.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 150518b..6bb8962 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -4,7 +4,7 @@ set -euo pipefail # Exit on error, undefined vars, pipe failures # Trap to catch any unexpected exits trap 'echo "[ERROR] Script exited unexpectedly at line $LINENO with exit code $?"' ERR EXIT -SCRIPT_VERSION="1.0.0-debug.7" +SCRIPT_VERSION="1.0.0-debug.8" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= @@ -206,20 +206,21 @@ while IFS= read -r line; do '$PATCHMON_URL/api/v1/hosts/install' && \ bash patchmon-install.sh && \ rm -f patchmon-install.sh - " 2>&1 &1 180s) in $friendly_name" - debug " Install output: $install_output" + info " Install output: $install_output" ((failed_count++)) else warn " ✗ Failed to install agent in $friendly_name (exit: $install_exit_code)" - debug " Install output: $install_output" + info " Install output: $install_output" ((failed_count++)) fi From 51982010db16e3d0fe85a47180c317145188b83a Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 13:41:12 +0100 Subject: [PATCH 15/51] fix: Pass API credentials directly to curl instead of via env vars --- agents/proxmox_auto_enroll.sh | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 6bb8962..c5aaa98 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -4,7 +4,7 @@ set -euo pipefail # Exit on error, undefined vars, pipe failures # Trap to catch any unexpected exits trap 'echo "[ERROR] Script exited unexpectedly at line $LINENO with exit code $?"' ERR EXIT -SCRIPT_VERSION="1.0.0-debug.8" +SCRIPT_VERSION="1.0.0-debug.9" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= @@ -196,12 +196,10 @@ while IFS= read -r line; do # Download and execute in separate steps to avoid stdin issues with piping install_output=$(timeout 180 pct exec "$vmid" -- bash -c " - export API_ID='$api_id' - export API_KEY='$api_key' cd /tmp curl $CURL_FLAGS \ - -H 'X-API-ID: \$API_ID' \ - -H 'X-API-KEY: \$API_KEY' \ + -H \"X-API-ID: $api_id\" \ + -H \"X-API-KEY: $api_key\" \ -o patchmon-install.sh \ '$PATCHMON_URL/api/v1/hosts/install' && \ bash patchmon-install.sh && \ From e0eb544205ffd3734c87f66d3d6593674e238737 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 13:42:09 +0100 Subject: [PATCH 16/51] fix: Make all counter increments safe with || true to prevent set -e exit --- agents/proxmox_auto_enroll.sh | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index c5aaa98..4ff08d7 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -4,7 +4,7 @@ set -euo pipefail # Exit on error, undefined vars, pipe failures # Trap to catch any unexpected exits trap 'echo "[ERROR] Script exited unexpectedly at line $LINENO with exit code $?"' ERR EXIT -SCRIPT_VERSION="1.0.0-debug.9" +SCRIPT_VERSION="1.0.0-debug.10" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= @@ -127,7 +127,7 @@ while IFS= read -r line; do # Skip stopped containers if configured if [[ "$status" != "running" ]] && [[ "$SKIP_STOPPED" == "true" ]]; then warn " Skipping $name - container not running" - ((skipped_count++)) + ((skipped_count++)) || true echo "" continue fi @@ -135,7 +135,7 @@ while IFS= read -r line; do # Check if container is stopped if [[ "$status" != "running" ]]; then warn " Container $name is stopped - cannot gather info or install agent" - ((skipped_count++)) + ((skipped_count++)) || true echo "" continue fi @@ -154,7 +154,7 @@ while IFS= read -r line; do if [[ "$DRY_RUN" == "true" ]]; then info " [DRY RUN] Would enroll: $friendly_name" - ((enrolled_count++)) + ((enrolled_count++)) || true echo "" continue fi @@ -211,27 +211,27 @@ while IFS= read -r line; do if [[ $install_exit_code -eq 0 ]]; then info " ✓ Agent installed successfully in $friendly_name" - ((enrolled_count++)) + ((enrolled_count++)) || true elif [[ $install_exit_code -eq 124 ]]; then warn " ⏱ Agent installation timed out (>180s) in $friendly_name" info " Install output: $install_output" - ((failed_count++)) + ((failed_count++)) || true else warn " ✗ Failed to install agent in $friendly_name (exit: $install_exit_code)" info " Install output: $install_output" - ((failed_count++)) + ((failed_count++)) || true fi elif [[ "$http_code" == "409" ]]; then warn " ⊘ Host $friendly_name already enrolled - skipping" - ((skipped_count++)) + ((skipped_count++)) || true elif [[ "$http_code" == "429" ]]; then error " ✗ Rate limit exceeded - maximum hosts per day reached" - ((failed_count++)) + ((failed_count++)) || true else error " ✗ Failed to enroll $friendly_name - HTTP $http_code" debug " Response: $body" - ((failed_count++)) + ((failed_count++)) || true fi echo "" From bbb97dbfda33d9727d583be7932fe37549d1a6a4 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 14:37:38 +0100 Subject: [PATCH 17/51] fix: Remove EXIT from error trap to prevent false failures on successful completion --- agents/proxmox_auto_enroll.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 4ff08d7..0c44b18 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -1,10 +1,10 @@ #!/bin/bash set -euo pipefail # Exit on error, undefined vars, pipe failures -# Trap to catch any unexpected exits -trap 'echo "[ERROR] Script exited unexpectedly at line $LINENO with exit code $?"' ERR EXIT +# Trap to catch errors only (not normal exits) +trap 'echo "[ERROR] Script failed at line $LINENO with exit code $?"' ERR -SCRIPT_VERSION="1.0.0-debug.10" +SCRIPT_VERSION="1.0.1" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= From 13c43421357eb4c50062286738120214fb0cb1c2 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 14:39:36 +0100 Subject: [PATCH 18/51] feat: Remove 'proxmox-' prefix from friendly names, use hostname only --- agents/proxmox_auto_enroll.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 0c44b18..d8d4599 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -4,7 +4,7 @@ set -euo pipefail # Exit on error, undefined vars, pipe failures # Trap to catch errors only (not normal exits) trap 'echo "[ERROR] Script failed at line $LINENO with exit code $?"' ERR -SCRIPT_VERSION="1.0.1" +SCRIPT_VERSION="1.0.2" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= @@ -29,7 +29,7 @@ AUTO_ENROLLMENT_KEY="${AUTO_ENROLLMENT_KEY:-}" AUTO_ENROLLMENT_SECRET="${AUTO_ENROLLMENT_SECRET:-}" CURL_FLAGS="${CURL_FLAGS:--s}" DRY_RUN="${DRY_RUN:-false}" -HOST_PREFIX="${HOST_PREFIX:-proxmox-}" +HOST_PREFIX="${HOST_PREFIX:-}" SKIP_STOPPED="${SKIP_STOPPED:-true}" PARALLEL_INSTALL="${PARALLEL_INSTALL:-false}" MAX_PARALLEL="${MAX_PARALLEL:-5}" From 513c268b369b49bb92fbc738a12e604c9e2cbcde Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 2 Oct 2025 15:14:50 +0100 Subject: [PATCH 19/51] fix: Reset install_exit_code per container and detect success via output message --- agents/proxmox_auto_enroll.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index d8d4599..b151fe0 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -4,7 +4,7 @@ set -euo pipefail # Exit on error, undefined vars, pipe failures # Trap to catch errors only (not normal exits) trap 'echo "[ERROR] Script failed at line $LINENO with exit code $?"' ERR -SCRIPT_VERSION="1.0.2" +SCRIPT_VERSION="1.0.3" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= @@ -194,6 +194,9 @@ while IFS= read -r line; do # Install PatchMon agent in container info " Installing PatchMon agent..." + # Reset exit code for this container + install_exit_code=0 + # Download and execute in separate steps to avoid stdin issues with piping install_output=$(timeout 180 pct exec "$vmid" -- bash -c " cd /tmp @@ -205,11 +208,9 @@ while IFS= read -r line; do bash patchmon-install.sh && \ rm -f patchmon-install.sh " 2>&1 Date: Thu, 2 Oct 2025 15:19:49 +0100 Subject: [PATCH 20/51] feat: Add interactive dpkg error recovery with automatic retry --- agents/proxmox_auto_enroll.sh | 86 ++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index b151fe0..ad573a9 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -4,7 +4,7 @@ set -euo pipefail # Exit on error, undefined vars, pipe failures # Trap to catch errors only (not normal exits) trap 'echo "[ERROR] Script failed at line $LINENO with exit code $?"' ERR -SCRIPT_VERSION="1.0.3" +SCRIPT_VERSION="1.1.0" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= @@ -112,6 +112,9 @@ info "Initializing statistics..." enrolled_count=0 skipped_count=0 failed_count=0 + +# Track containers with dpkg errors for later recovery +declare -A dpkg_error_containers info "Statistics initialized" # ===== PROCESS CONTAINERS ===== @@ -218,7 +221,13 @@ while IFS= read -r line; do info " Install output: $install_output" ((failed_count++)) || true else - warn " ✗ Failed to install agent in $friendly_name (exit: $install_exit_code)" + # Check if it's a dpkg error + if [[ "$install_output" == *"dpkg was interrupted"* ]] || [[ "$install_output" == *"dpkg --configure -a"* ]]; then + warn " ⚠ Failed due to dpkg error in $friendly_name (can be fixed)" + dpkg_error_containers["$vmid"]="$friendly_name:$api_id:$api_key" + else + warn " ✗ Failed to install agent in $friendly_name (exit: $install_exit_code)" + fi info " Install output: $install_output" ((failed_count++)) || true fi @@ -257,6 +266,79 @@ if [[ "$DRY_RUN" == "true" ]]; then warn "Set DRY_RUN=false to perform actual enrollment" fi +# ===== DPKG ERROR RECOVERY ===== +if [[ ${#dpkg_error_containers[@]} -gt 0 ]]; then + echo "" + echo "╔═══════════════════════════════════════════════════════════════╗" + echo "║ DPKG ERROR RECOVERY AVAILABLE ║" + echo "╚═══════════════════════════════════════════════════════════════╝" + echo "" + warn "Detected ${#dpkg_error_containers[@]} container(s) with dpkg errors:" + for vmid in "${!dpkg_error_containers[@]}"; do + IFS=':' read -r name api_id api_key <<< "${dpkg_error_containers[$vmid]}" + info " • Container $vmid: $name" + done + echo "" + + # Ask user if they want to fix dpkg errors + read -p "Would you like to fix dpkg errors and retry installation? (y/N): " -n 1 -r + echo "" + + if [[ $REPLY =~ ^[Yy]$ ]]; then + echo "" + info "Starting dpkg recovery process..." + echo "" + + recovered_count=0 + + for vmid in "${!dpkg_error_containers[@]}"; do + IFS=':' read -r name api_id api_key <<< "${dpkg_error_containers[$vmid]}" + + info "Fixing dpkg in container $vmid ($name)..." + + # Run dpkg --configure -a + dpkg_output=$(timeout 60 pct exec "$vmid" -- dpkg --configure -a 2>&1 &1 Date: Wed, 1 Oct 2025 23:48:13 +0100 Subject: [PATCH 21/51] fix(auth): JWT_SECRET is required --- backend/src/middleware/auth.js | 16 ++++++++-------- backend/src/routes/authRoutes.js | 5 ++++- backend/src/utils/session_manager.js | 5 ++++- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index d0d82cb..f13606f 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -18,10 +18,10 @@ const authenticateToken = async (req, res, next) => { } // Verify token - const decoded = jwt.verify( - token, - process.env.JWT_SECRET || "your-secret-key", - ); + if (!process.env.JWT_SECRET) { + throw new Error("JWT_SECRET environment variable is required"); + } + const decoded = jwt.verify(token, process.env.JWT_SECRET); // Validate session and check inactivity timeout const validation = await validate_session(decoded.sessionId, token); @@ -85,10 +85,10 @@ const optionalAuth = async (req, _res, next) => { const token = authHeader?.split(" ")[1]; if (token) { - const decoded = jwt.verify( - token, - process.env.JWT_SECRET || "your-secret-key", - ); + if (!process.env.JWT_SECRET) { + throw new Error("JWT_SECRET environment variable is required"); + } + const decoded = jwt.verify(token, process.env.JWT_SECRET); const user = await prisma.users.findUnique({ where: { id: decoded.userId }, select: { diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js index 9d04c58..6f68209 100644 --- a/backend/src/routes/authRoutes.js +++ b/backend/src/routes/authRoutes.js @@ -156,7 +156,10 @@ router.post( // Generate JWT token const generateToken = (userId) => { - return jwt.sign({ userId }, process.env.JWT_SECRET || "your-secret-key", { + if (!process.env.JWT_SECRET) { + throw new Error("JWT_SECRET environment variable is required"); + } + return jwt.sign({ userId }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN || "24h", }); }; diff --git a/backend/src/utils/session_manager.js b/backend/src/utils/session_manager.js index b941ec5..d70a7c0 100644 --- a/backend/src/utils/session_manager.js +++ b/backend/src/utils/session_manager.js @@ -9,7 +9,10 @@ const prisma = new PrismaClient(); */ // Configuration -const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; +if (!process.env.JWT_SECRET) { + throw new Error("JWT_SECRET environment variable is required"); +} +const JWT_SECRET = process.env.JWT_SECRET; const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "1h"; const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || "7d"; const INACTIVITY_TIMEOUT_MINUTES = parseInt( From b454b8d13039243acf3a4b4fdd6a301ab0559f0e Mon Sep 17 00:00:00 2001 From: tigattack <10629864+tigattack@users.noreply.github.com> Date: Wed, 1 Oct 2025 02:01:38 +0100 Subject: [PATCH 22/51] feat(packages): show all packages by default, add pagination --- backend/src/routes/packageRoutes.js | 18 +- frontend/src/pages/Packages.jsx | 296 ++++++++++++++++++++++------ 2 files changed, 244 insertions(+), 70 deletions(-) diff --git a/backend/src/routes/packageRoutes.js b/backend/src/routes/packageRoutes.js index e611bd3..e948526 100644 --- a/backend/src/routes/packageRoutes.js +++ b/backend/src/routes/packageRoutes.js @@ -67,7 +67,9 @@ router.get("/", async (req, res) => { latest_version: true, created_at: true, _count: { - host_packages: true, + select: { + host_packages: true, + }, }, }, skip, @@ -82,7 +84,7 @@ router.get("/", async (req, res) => { // Get additional stats for each package const packagesWithStats = await Promise.all( packages.map(async (pkg) => { - const [updatesCount, securityCount, affectedHosts] = await Promise.all([ + const [updatesCount, securityCount, packageHosts] = await Promise.all([ prisma.host_packages.count({ where: { package_id: pkg.id, @@ -117,17 +119,17 @@ router.get("/", async (req, res) => { return { ...pkg, - affectedHostsCount: pkg._count.hostPackages, - affectedHosts: affectedHosts.map((hp) => ({ - hostId: hp.host.id, - friendlyName: hp.host.friendly_name, - osType: hp.host.os_type, + packageHostsCount: pkg._count.host_packages, + packageHosts: packageHosts.map((hp) => ({ + hostId: hp.hosts.id, + friendlyName: hp.hosts.friendly_name, + osType: hp.hosts.os_type, currentVersion: hp.current_version, availableVersion: hp.available_version, isSecurityUpdate: hp.is_security_update, })), stats: { - totalInstalls: pkg._count.hostPackages, + totalInstalls: pkg._count.host_packages, updatesNeeded: updatesCount, securityUpdates: securityCount, }, diff --git a/frontend/src/pages/Packages.jsx b/frontend/src/pages/Packages.jsx index ebb088d..75ca544 100644 --- a/frontend/src/pages/Packages.jsx +++ b/frontend/src/pages/Packages.jsx @@ -4,6 +4,8 @@ import { ArrowDown, ArrowUp, ArrowUpDown, + ChevronLeft, + ChevronRight, Columns, Eye as EyeIcon, EyeOff as EyeOffIcon, @@ -17,16 +19,28 @@ import { } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; -import { dashboardAPI } from "../utils/api"; +import { dashboardAPI, packagesAPI } from "../utils/api"; const Packages = () => { const [searchTerm, setSearchTerm] = useState(""); const [categoryFilter, setCategoryFilter] = useState("all"); - const [securityFilter, setSecurityFilter] = useState("all"); + const [updateStatusFilter, setUpdateStatusFilter] = useState("all-packages"); const [hostFilter, setHostFilter] = useState("all"); const [sortField, setSortField] = useState("name"); const [sortDirection, setSortDirection] = useState("asc"); const [showColumnSettings, setShowColumnSettings] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(() => { + const saved = localStorage.getItem("packages-page-size"); + if (saved) { + const parsedSize = parseInt(saved, 10); + // Validate that the saved page size is one of the allowed values + if ([25, 50, 100, 200].includes(parsedSize)) { + return parsedSize; + } + } + return 25; // Default fallback + }); const [searchParams] = useSearchParams(); const navigate = useNavigate(); @@ -42,8 +56,8 @@ const Packages = () => { const [columnConfig, setColumnConfig] = useState(() => { const defaultConfig = [ { id: "name", label: "Package", visible: true, order: 0 }, - { id: "affectedHosts", label: "Affected Hosts", visible: true, order: 1 }, - { id: "priority", label: "Priority", visible: true, order: 2 }, + { id: "packageHosts", label: "Installed On", visible: true, order: 1 }, + { id: "status", label: "Status", visible: true, order: 2 }, { id: "latestVersion", label: "Latest Version", visible: true, order: 3 }, ]; @@ -65,10 +79,10 @@ const Packages = () => { localStorage.setItem("packages-column-config", JSON.stringify(newConfig)); }; - // Handle affected hosts click - const handleAffectedHostsClick = (pkg) => { - const affectedHosts = pkg.affectedHosts || []; - const hostIds = affectedHosts.map((host) => host.hostId); + // Handle hosts click (view hosts where package is installed) + const handlePackageHostsClick = (pkg) => { + const packageHosts = pkg.packageHosts || []; + const hostIds = packageHosts.map((host) => host.hostId); // Create URL with selected hosts and filter const params = new URLSearchParams(); @@ -86,27 +100,43 @@ const Packages = () => { // For outdated packages, we want to show all packages that need updates // This is the default behavior, so we don't need to change filters setCategoryFilter("all"); - setSecurityFilter("all"); + setUpdateStatusFilter("needs-updates"); } else if (filter === "security") { // For security updates, filter to show only security updates - setSecurityFilter("security"); + setUpdateStatusFilter("security-updates"); setCategoryFilter("all"); } }, [searchParams]); const { - data: packages, + data: packagesResponse, isLoading, error, refetch, isFetching, } = useQuery({ queryKey: ["packages"], - queryFn: () => dashboardAPI.getPackages().then((res) => res.data), + queryFn: () => packagesAPI.getAll({ limit: 1000 }).then((res) => res.data), staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes refetchOnWindowFocus: false, // Don't refetch when window regains focus }); + // Extract packages from the response and normalise the data structure + const packages = useMemo(() => { + if (!packagesResponse?.packages) return []; + + return packagesResponse.packages.map((pkg) => ({ + ...pkg, + // Normalise field names to match the frontend expectations + packageHostsCount: pkg.packageHostsCount || pkg.stats?.totalInstalls || 0, + latestVersion: pkg.latest_version || pkg.latestVersion || "Unknown", + isUpdatable: (pkg.stats?.updatesNeeded || 0) > 0, + isSecurityUpdate: (pkg.stats?.securityUpdates || 0) > 0, + // Ensure we have hosts array (for packages, this contains all hosts where the package is installed) + packageHosts: pkg.packageHosts || [], + })); + }, [packagesResponse]); + // Fetch hosts data to get total packages count const { data: hosts } = useQuery({ queryKey: ["hosts"], @@ -128,17 +158,30 @@ const Packages = () => { const matchesCategory = categoryFilter === "all" || pkg.category === categoryFilter; - const matchesSecurity = - securityFilter === "all" || - (securityFilter === "security" && pkg.isSecurityUpdate) || - (securityFilter === "regular" && !pkg.isSecurityUpdate); + const matchesUpdateStatus = + updateStatusFilter === "all-packages" || + updateStatusFilter === "needs-updates" || + (updateStatusFilter === "security-updates" && pkg.isSecurityUpdate) || + (updateStatusFilter === "regular-updates" && !pkg.isSecurityUpdate); - const affectedHosts = pkg.affectedHosts || []; + // For "all-packages", we don't filter by update status + // For other filters, we only show packages that need updates + const matchesUpdateNeeded = + updateStatusFilter === "all-packages" || + (pkg.stats?.updatesNeeded || 0) > 0; + + const packageHosts = pkg.packageHosts || []; const matchesHost = hostFilter === "all" || - affectedHosts.some((host) => host.hostId === hostFilter); + packageHosts.some((host) => host.hostId === hostFilter); - return matchesSearch && matchesCategory && matchesSecurity && matchesHost; + return ( + matchesSearch && + matchesCategory && + matchesUpdateStatus && + matchesUpdateNeeded && + matchesHost + ); }); // Sorting @@ -154,14 +197,38 @@ const Packages = () => { aValue = a.latestVersion?.toLowerCase() || ""; bValue = b.latestVersion?.toLowerCase() || ""; break; - case "affectedHosts": - aValue = a.affectedHostsCount || a.affectedHosts?.length || 0; - bValue = b.affectedHostsCount || b.affectedHosts?.length || 0; + case "packageHosts": + aValue = a.packageHostsCount || a.packageHosts?.length || 0; + bValue = b.packageHostsCount || b.packageHosts?.length || 0; break; - case "priority": - aValue = a.isSecurityUpdate ? 0 : 1; // Security updates first - bValue = b.isSecurityUpdate ? 0 : 1; + case "status": { + // Handle sorting for the three status states: Up to Date, Update Available, Security Update Available + const aNeedsUpdates = (a.stats?.updatesNeeded || 0) > 0; + const bNeedsUpdates = (b.stats?.updatesNeeded || 0) > 0; + + // Define priority order: Security Update (0) > Regular Update (1) > Up to Date (2) + let aPriority, bPriority; + + if (!aNeedsUpdates) { + aPriority = 2; // Up to Date + } else if (a.isSecurityUpdate) { + aPriority = 0; // Security Update + } else { + aPriority = 1; // Regular Update + } + + if (!bNeedsUpdates) { + bPriority = 2; // Up to Date + } else if (b.isSecurityUpdate) { + bPriority = 0; // Security Update + } else { + bPriority = 1; // Regular Update + } + + aValue = aPriority; + bValue = bPriority; break; + } default: aValue = a.name?.toLowerCase() || ""; bValue = b.name?.toLowerCase() || ""; @@ -177,12 +244,33 @@ const Packages = () => { packages, searchTerm, categoryFilter, - securityFilter, + updateStatusFilter, sortField, sortDirection, hostFilter, ]); + // Calculate pagination + const totalPages = Math.ceil(filteredAndSortedPackages.length / pageSize); + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + const paginatedPackages = filteredAndSortedPackages.slice( + startIndex, + endIndex, + ); + + // Reset to first page when filters or page size change + // biome-ignore lint/correctness/useExhaustiveDependencies: We want this effect to run when filter values or page size change to reset pagination + useEffect(() => { + setCurrentPage(1); + }, [searchTerm, categoryFilter, updateStatusFilter, hostFilter, pageSize]); + + // Function to handle page size change and save to localStorage + const handlePageSizeChange = (newPageSize) => { + setPageSize(newPageSize); + localStorage.setItem("packages-page-size", newPageSize.toString()); + }; + // Get visible columns in order const visibleColumns = columnConfig .filter((col) => col.visible) @@ -231,8 +319,8 @@ const Packages = () => { const resetColumns = () => { const defaultConfig = [ { id: "name", label: "Package", visible: true, order: 0 }, - { id: "affectedHosts", label: "Affected Hosts", visible: true, order: 1 }, - { id: "priority", label: "Priority", visible: true, order: 2 }, + { id: "packageHosts", label: "Installed On", visible: true, order: 1 }, + { id: "status", label: "Status", visible: true, order: 2 }, { id: "latestVersion", label: "Latest Version", visible: true, order: 3 }, ]; updateColumnConfig(defaultConfig); @@ -262,31 +350,56 @@ const Packages = () => {
); - case "affectedHosts": { - const affectedHostsCount = - pkg.affectedHostsCount || pkg.affectedHosts?.length || 0; + case "packageHosts": { + // Show total number of hosts where this package is installed + const installedHostsCount = + pkg.packageHostsCount || + pkg.stats?.totalInstalls || + pkg.packageHosts?.length || + 0; + // For packages that need updates, show how many need updates + const hostsNeedingUpdates = pkg.stats?.updatesNeeded || 0; + + const displayText = + hostsNeedingUpdates > 0 && hostsNeedingUpdates < installedHostsCount + ? `${hostsNeedingUpdates}/${installedHostsCount} hosts` + : `${installedHostsCount} host${installedHostsCount !== 1 ? "s" : ""}`; + + const titleText = + hostsNeedingUpdates > 0 && hostsNeedingUpdates < installedHostsCount + ? `${hostsNeedingUpdates} of ${installedHostsCount} hosts need updates` + : `Installed on ${installedHostsCount} host${installedHostsCount !== 1 ? "s" : ""}`; + return ( ); } - case "priority": + case "status": { + // Check if this package needs updates + const needsUpdates = (pkg.stats?.updatesNeeded || 0) > 0; + + if (!needsUpdates) { + return Up to Date; + } + return pkg.isSecurityUpdate ? ( - Security Update + Security Update Available ) : ( - Regular Update + Update Available ); + } case "latestVersion": return (
{ const categories = [...new Set(packages?.map((pkg) => pkg.category).filter(Boolean))] || []; - // Calculate unique affected hosts - const uniqueAffectedHosts = new Set(); + // Calculate unique package hosts + const uniquePackageHosts = new Set(); packages?.forEach((pkg) => { - const affectedHosts = pkg.affectedHosts || []; - affectedHosts.forEach((host) => { - uniqueAffectedHosts.add(host.hostId); - }); + // Only count hosts for packages that need updates + if ((pkg.stats?.updatesNeeded || 0) > 0) { + const packageHosts = pkg.packageHosts || []; + packageHosts.forEach((host) => { + uniquePackageHosts.add(host.hostId); + }); + } }); - const uniqueAffectedHostsCount = uniqueAffectedHosts.size; + const uniquePackageHostsCount = uniquePackageHosts.size; - // Calculate total packages across all hosts (including up-to-date ones) - const totalPackagesCount = - hosts?.reduce((total, host) => { - return total + (host.totalPackagesCount || 0); - }, 0) || 0; + // Calculate total packages available + const totalPackagesCount = packages?.length || 0; - // Calculate outdated packages (packages that need updates) - const outdatedPackagesCount = packages?.length || 0; + // Calculate outdated packages + const outdatedPackagesCount = + packages?.filter((pkg) => (pkg.stats?.updatesNeeded || 0) > 0).length || 0; // Calculate security updates const securityUpdatesCount = - packages?.filter((pkg) => pkg.isSecurityUpdate).length || 0; + packages?.filter((pkg) => (pkg.stats?.securityUpdates || 0) > 0).length || + 0; if (isLoading) { return ( @@ -429,7 +544,7 @@ const Packages = () => { Hosts Pending Updates

- {uniqueAffectedHostsCount} + {uniquePackageHostsCount}

@@ -490,16 +605,21 @@ const Packages = () => {
- {/* Security Filter */} + {/* Update Status Filter */}
@@ -539,12 +659,13 @@ const Packages = () => {

{packages?.length === 0 - ? "No packages need updates" + ? "No packages found" : "No packages match your filters"}

{packages?.length === 0 && (

- All packages are up to date across all hosts + Packages will appear here once hosts start reporting their + installed packages

)}
@@ -571,7 +692,7 @@ const Packages = () => { - {filteredAndSortedPackages.map((pkg) => ( + {paginatedPackages.map((pkg) => ( {
)} + + {/* Pagination Controls */} + {filteredAndSortedPackages.length > 0 && ( +
+
+
+ + Rows per page: + + +
+ + {startIndex + 1}- + {Math.min(endIndex, filteredAndSortedPackages.length)} of{" "} + {filteredAndSortedPackages.length} + +
+
+ + + Page {currentPage} of {totalPages} + + +
+
+ )} From 8bb16f08966fa666d938245456c041be0dd4f2e9 Mon Sep 17 00:00:00 2001 From: tigattack <10629864+tigattack@users.noreply.github.com> Date: Wed, 1 Oct 2025 21:59:45 +0100 Subject: [PATCH 23/51] fix(api): update package host fields to match database schema --- backend/src/routes/packageRoutes.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/src/routes/packageRoutes.js b/backend/src/routes/packageRoutes.js index e948526..6a7a54b 100644 --- a/backend/src/routes/packageRoutes.js +++ b/backend/src/routes/packageRoutes.js @@ -162,19 +162,19 @@ router.get("/:packageId", async (req, res) => { include: { host_packages: { include: { - host: { + hosts: { select: { id: true, hostname: true, ip: true, - osType: true, - osVersion: true, - lastUpdate: true, + os_type: true, + os_version: true, + last_update: true, }, }, }, orderBy: { - needsUpdate: "desc", + needs_update: "desc", }, }, }, @@ -187,25 +187,25 @@ router.get("/:packageId", async (req, res) => { // Calculate statistics const stats = { totalInstalls: packageData.host_packages.length, - updatesNeeded: packageData.host_packages.filter((hp) => hp.needsUpdate) + updatesNeeded: packageData.host_packages.filter((hp) => hp.needs_update) .length, securityUpdates: packageData.host_packages.filter( - (hp) => hp.needsUpdate && hp.isSecurityUpdate, + (hp) => hp.needs_update && hp.is_security_update, ).length, - upToDate: packageData.host_packages.filter((hp) => !hp.needsUpdate) + upToDate: packageData.host_packages.filter((hp) => !hp.needs_update) .length, }; // Group by version const versionDistribution = packageData.host_packages.reduce((acc, hp) => { - const version = hp.currentVersion; + const version = hp.current_version; acc[version] = (acc[version] || 0) + 1; return acc; }, {}); // Group by OS type const osDistribution = packageData.host_packages.reduce((acc, hp) => { - const osType = hp.host.osType; + const osType = hp.hosts.os_type; acc[osType] = (acc[osType] || 0) + 1; return acc; }, {}); From 6f59a1981d0f35e1951d3a0b3e3d3a1831eef02d Mon Sep 17 00:00:00 2001 From: tigattack <10629864+tigattack@users.noreply.github.com> Date: Wed, 1 Oct 2025 22:01:45 +0100 Subject: [PATCH 24/51] feat(api): endpoint to retrieve hosts for a pkg With pagination and search functionality --- backend/src/routes/packageRoutes.js | 105 ++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/backend/src/routes/packageRoutes.js b/backend/src/routes/packageRoutes.js index 6a7a54b..4188b06 100644 --- a/backend/src/routes/packageRoutes.js +++ b/backend/src/routes/packageRoutes.js @@ -232,4 +232,109 @@ router.get("/:packageId", async (req, res) => { } }); +// Get hosts where a package is installed +router.get("/:packageId/hosts", async (req, res) => { + try { + const { packageId } = req.params; + const { + page = 1, + limit = 25, + search = "", + sortBy = "friendly_name", + sortOrder = "asc", + } = req.query; + + const offset = (parseInt(page, 10) - 1) * parseInt(limit, 10); + + // Build search conditions + const searchConditions = search + ? { + OR: [ + { + hosts: { + friendly_name: { contains: search, mode: "insensitive" }, + }, + }, + { hosts: { hostname: { contains: search, mode: "insensitive" } } }, + { current_version: { contains: search, mode: "insensitive" } }, + { available_version: { contains: search, mode: "insensitive" } }, + ], + } + : {}; + + // Build sort conditions + const orderBy = {}; + if ( + sortBy === "friendly_name" || + sortBy === "hostname" || + sortBy === "os_type" + ) { + orderBy.hosts = { [sortBy]: sortOrder }; + } else if (sortBy === "needs_update") { + orderBy[sortBy] = sortOrder; + } else { + orderBy[sortBy] = sortOrder; + } + + // Get total count + const totalCount = await prisma.host_packages.count({ + where: { + package_id: packageId, + ...searchConditions, + }, + }); + + // Get paginated results + const hostPackages = await prisma.host_packages.findMany({ + where: { + package_id: packageId, + ...searchConditions, + }, + include: { + hosts: { + select: { + id: true, + friendly_name: true, + hostname: true, + os_type: true, + os_version: true, + last_update: true, + }, + }, + }, + orderBy, + skip: offset, + take: parseInt(limit, 10), + }); + + // Transform the data for the frontend + const hosts = hostPackages.map((hp) => ({ + hostId: hp.hosts.id, + friendlyName: hp.hosts.friendly_name, + hostname: hp.hosts.hostname, + osType: hp.hosts.os_type, + osVersion: hp.hosts.os_version, + lastUpdate: hp.hosts.last_update, + currentVersion: hp.current_version, + availableVersion: hp.available_version, + needsUpdate: hp.needs_update, + isSecurityUpdate: hp.is_security_update, + lastChecked: hp.last_checked, + })); + + res.json({ + hosts, + pagination: { + page: parseInt(page, 10), + limit: parseInt(limit, 10), + total: totalCount, + pages: Math.ceil(totalCount / parseInt(limit, 10)), + }, + }); + } catch (error) { + console.error("Error fetching package hosts:", error); + res.status(500).json({ error: "Failed to fetch package hosts" }); + } +}); + module.exports = router; From fffc571453c31805e471631a0a1cf25f030b1d5b Mon Sep 17 00:00:00 2001 From: tigattack <10629864+tigattack@users.noreply.github.com> Date: Wed, 1 Oct 2025 22:02:15 +0100 Subject: [PATCH 25/51] feat(packages): complete package detail page Open by clicking package name --- frontend/src/pages/PackageDetail.jsx | 483 ++++++++++++++++++++++++++- frontend/src/pages/Packages.jsx | 14 +- 2 files changed, 478 insertions(+), 19 deletions(-) diff --git a/frontend/src/pages/PackageDetail.jsx b/frontend/src/pages/PackageDetail.jsx index a1183c7..8eb1abe 100644 --- a/frontend/src/pages/PackageDetail.jsx +++ b/frontend/src/pages/PackageDetail.jsx @@ -1,23 +1,478 @@ -import { Package } from "lucide-react"; -import { useParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { + AlertTriangle, + ArrowLeft, + Calendar, + ChartColumnBig, + ChevronRight, + Download, + Package, + RefreshCw, + Search, + Server, + Shield, + Tag, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { formatRelativeTime, packagesAPI } from "../utils/api"; const PackageDetail = () => { const { packageId } = useParams(); + const decodedPackageId = decodeURIComponent(packageId || ""); + const navigate = useNavigate(); + const [searchTerm, setSearchTerm] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + + // Fetch package details + const { + data: packageData, + isLoading: isLoadingPackage, + error: packageError, + refetch: refetchPackage, + } = useQuery({ + queryKey: ["package", decodedPackageId], + queryFn: () => + packagesAPI.getById(decodedPackageId).then((res) => res.data), + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + enabled: !!decodedPackageId, + }); + + // Fetch hosts that have this package + const { + data: hostsData, + isLoading: isLoadingHosts, + error: hostsError, + refetch: refetchHosts, + } = useQuery({ + queryKey: ["package-hosts", decodedPackageId, searchTerm], + queryFn: () => + packagesAPI + .getHosts(decodedPackageId, { search: searchTerm, limit: 1000 }) + .then((res) => res.data), + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + enabled: !!decodedPackageId, + }); + + const hosts = hostsData?.hosts || []; + + // Filter and paginate hosts + const filteredAndPaginatedHosts = useMemo(() => { + let filtered = hosts; + + if (searchTerm) { + filtered = hosts.filter( + (host) => + host.friendly_name + ?.toLowerCase() + .includes(searchTerm.toLowerCase()) || + host.hostname?.toLowerCase().includes(searchTerm.toLowerCase()), + ); + } + + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + return filtered.slice(startIndex, endIndex); + }, [hosts, searchTerm, currentPage, pageSize]); + + const totalPages = Math.ceil( + (searchTerm + ? hosts.filter( + (host) => + host.friendly_name + ?.toLowerCase() + .includes(searchTerm.toLowerCase()) || + host.hostname?.toLowerCase().includes(searchTerm.toLowerCase()), + ).length + : hosts.length) / pageSize, + ); + + const handleHostClick = (hostId) => { + navigate(`/hosts/${hostId}`); + }; + + const handleRefresh = () => { + refetchPackage(); + refetchHosts(); + }; + + if (isLoadingPackage) { + return ( +
+ +
+ ); + } + + if (packageError) { + return ( +
+
+
+ +
+

+ Error loading package +

+

+ {packageError.message || "Failed to load package details"} +

+ +
+
+
+
+ ); + } + + if (!packageData) { + return ( +
+
+ +

+ Package not found +

+
+
+ ); + } + + const pkg = packageData; + const stats = packageData.stats || {}; return (
-
- -

- Package Details -

-

- Detailed view for package: {packageId} -

-

- This page will show package information, affected hosts, version - distribution, and more. -

+ {/* Header */} +
+
+ + +

+ {pkg.name} +

+
+ +
+ + {/* Package Overview */} +
+ {/* Main Package Info */} +
+
+
+ +
+

+ {pkg.name} +

+ {pkg.description && ( +

+ {pkg.description} +

+ )} +
+ {pkg.category && ( +
+ + + Category: {pkg.category} + +
+ )} + {pkg.latest_version && ( +
+ + + Latest: {pkg.latest_version} + +
+ )} + {pkg.updated_at && ( +
+ + + Updated: {formatRelativeTime(pkg.updated_at)} + +
+ )} +
+
+
+ + {/* Status Badge */} +
+ {stats.updatesNeeded > 0 ? ( + stats.securityUpdates > 0 ? ( + + + Security Update Available + + ) : ( + Update Available + ) + ) : ( + Up to Date + )} +
+
+
+ + {/* Statistics */} +
+
+
+ +

+ Installation Stats +

+
+
+
+ + Total Installations + + + {stats.totalInstalls || 0} + +
+ {stats.updatesNeeded > 0 && ( +
+ + Hosts Needing Updates + + + {stats.updatesNeeded} + +
+ )} + {stats.securityUpdates > 0 && ( +
+ + Security Updates + + + {stats.securityUpdates} + +
+ )} +
+ + Up to Date + + + {(stats.totalInstalls || 0) - (stats.updatesNeeded || 0)} + +
+
+
+
+
+ + {/* Hosts List */} +
+
+
+
+ +

+ Installed On Hosts ({hosts.length}) +

+
+
+ + {/* Search */} +
+ + { + setSearchTerm(e.target.value); + setCurrentPage(1); + }} + className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400" + /> +
+
+ +
+ {isLoadingHosts ? ( +
+ +
+ ) : hostsError ? ( +
+
+
+ +
+

+ Error loading hosts +

+

+ {hostsError.message || "Failed to load hosts"} +

+
+
+
+
+ ) : filteredAndPaginatedHosts.length === 0 ? ( +
+ +

+ {searchTerm + ? "No hosts match your search" + : "No hosts have this package installed"} +

+
+ ) : ( + <> + + + + + + + + + + + {filteredAndPaginatedHosts.map((host) => ( + handleHostClick(host.id)} + > + + + + + + ))} + +
+ Host + + Current Version + + Status + + Last Updated +
+
+ +
+
+ {host.friendly_name || host.hostname} +
+ {host.friendly_name && host.hostname && ( +
+ {host.hostname} +
+ )} +
+
+
+ {host.current_version || "Unknown"} + + {host.needs_update ? ( + host.is_security_update ? ( + + + Security Update + + ) : ( + + Update Available + + ) + ) : ( + + Up to Date + + )} + + {host.last_updated + ? formatRelativeTime(host.last_updated) + : "Never"} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+
+ + Rows per page: + + +
+
+ + + Page {currentPage} of {totalPages} + + +
+
+ )} + + )} +
); diff --git a/frontend/src/pages/Packages.jsx b/frontend/src/pages/Packages.jsx index 75ca544..6cc3801 100644 --- a/frontend/src/pages/Packages.jsx +++ b/frontend/src/pages/Packages.jsx @@ -331,10 +331,14 @@ const Packages = () => { switch (column.id) { case "name": return ( -
- -
-
+
-
+ ); case "packageHosts": { // Show total number of hosts where this package is installed From 757feab9cd32b1bb58d2a314565080efa809b622 Mon Sep 17 00:00:00 2001 From: tigattack <10629864+tigattack@users.noreply.github.com> Date: Wed, 1 Oct 2025 22:10:19 +0100 Subject: [PATCH 26/51] fix(packages): add needsUpdate and isSecurityUpdate fields to package hosts --- backend/src/routes/packageRoutes.js | 1 + frontend/src/pages/PackageDetail.jsx | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/src/routes/packageRoutes.js b/backend/src/routes/packageRoutes.js index 4188b06..ee2186f 100644 --- a/backend/src/routes/packageRoutes.js +++ b/backend/src/routes/packageRoutes.js @@ -126,6 +126,7 @@ router.get("/", async (req, res) => { osType: hp.hosts.os_type, currentVersion: hp.current_version, availableVersion: hp.available_version, + needsUpdate: hp.needs_update, isSecurityUpdate: hp.is_security_update, })), stats: { diff --git a/frontend/src/pages/PackageDetail.jsx b/frontend/src/pages/PackageDetail.jsx index 8eb1abe..06db020 100644 --- a/frontend/src/pages/PackageDetail.jsx +++ b/frontend/src/pages/PackageDetail.jsx @@ -400,8 +400,8 @@ const PackageDetail = () => { {host.current_version || "Unknown"} - {host.needs_update ? ( - host.is_security_update ? ( + {host.needsUpdate ? ( + host.isSecurityUpdate ? ( Security Update @@ -418,8 +418,8 @@ const PackageDetail = () => { )} - {host.last_updated - ? formatRelativeTime(host.last_updated) + {host.lastUpdate + ? formatRelativeTime(host.lastUpdate) : "Never"} From f085596b87f1b0ffa750637497ff156cf947fe1d Mon Sep 17 00:00:00 2001 From: tigattack <10629864+tigattack@users.noreply.github.com> Date: Thu, 2 Oct 2025 23:22:42 +0100 Subject: [PATCH 27/51] fix(packages): update host property names --- frontend/src/pages/PackageDetail.jsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/frontend/src/pages/PackageDetail.jsx b/frontend/src/pages/PackageDetail.jsx index 06db020..6df6d17 100644 --- a/frontend/src/pages/PackageDetail.jsx +++ b/frontend/src/pages/PackageDetail.jsx @@ -66,9 +66,7 @@ const PackageDetail = () => { if (searchTerm) { filtered = hosts.filter( (host) => - host.friendly_name - ?.toLowerCase() - .includes(searchTerm.toLowerCase()) || + host.friendlyName?.toLowerCase().includes(searchTerm.toLowerCase()) || host.hostname?.toLowerCase().includes(searchTerm.toLowerCase()), ); } @@ -82,7 +80,7 @@ const PackageDetail = () => { (searchTerm ? hosts.filter( (host) => - host.friendly_name + host.friendlyName ?.toLowerCase() .includes(searchTerm.toLowerCase()) || host.hostname?.toLowerCase().includes(searchTerm.toLowerCase()), @@ -377,18 +375,18 @@ const PackageDetail = () => { {filteredAndPaginatedHosts.map((host) => ( handleHostClick(host.id)} + onClick={() => handleHostClick(host.hostId)} >
- {host.friendly_name || host.hostname} + {host.friendlyName || host.hostname}
- {host.friendly_name && host.hostname && ( + {host.friendlyName && host.hostname && (
{host.hostname}
@@ -397,7 +395,7 @@ const PackageDetail = () => {
- {host.current_version || "Unknown"} + {host.currentVersion || "Unknown"} {host.needsUpdate ? ( From 482a9e27c9b51af0d8e3090b9146627c8004a903 Mon Sep 17 00:00:00 2001 From: tigattack <10629864+tigattack@users.noreply.github.com> Date: Thu, 2 Oct 2025 23:26:14 +0100 Subject: [PATCH 28/51] fix(packages): fix security update badge --- frontend/src/pages/Packages.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/Packages.jsx b/frontend/src/pages/Packages.jsx index 6cc3801..ffac6bb 100644 --- a/frontend/src/pages/Packages.jsx +++ b/frontend/src/pages/Packages.jsx @@ -396,7 +396,7 @@ const Packages = () => { } return pkg.isSecurityUpdate ? ( - + Security Update Available From a13b4941cd213668e5acbe4ca705a7d0151e9145 Mon Sep 17 00:00:00 2001 From: tigattack <10629864+tigattack@users.noreply.github.com> Date: Wed, 1 Oct 2025 01:14:48 +0100 Subject: [PATCH 29/51] refactor(repository): use server icon in repository host count display --- frontend/src/pages/Repositories.jsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/Repositories.jsx b/frontend/src/pages/Repositories.jsx index ef91b80..88b0fd5 100644 --- a/frontend/src/pages/Repositories.jsx +++ b/frontend/src/pages/Repositories.jsx @@ -16,7 +16,6 @@ import { Shield, ShieldCheck, Unlock, - Users, X, } from "lucide-react"; import { useMemo, useState } from "react"; @@ -513,8 +512,8 @@ const Repositories = () => { case "hostCount": return (
- - {repo.host_count} + + {repo.hostCount}
); case "actions": From 5ccd0aa163951fae4b6d2bf9f77654fffbfb30c3 Mon Sep 17 00:00:00 2001 From: tigattack <10629864+tigattack@users.noreply.github.com> Date: Thu, 2 Oct 2025 23:51:24 +0100 Subject: [PATCH 30/51] feat(repository): make hosts in repo detail more consistent with package detail --- frontend/src/pages/RepositoryDetail.jsx | 279 +++++++++++++++++------- 1 file changed, 201 insertions(+), 78 deletions(-) diff --git a/frontend/src/pages/RepositoryDetail.jsx b/frontend/src/pages/RepositoryDetail.jsx index 516f703..8253565 100644 --- a/frontend/src/pages/RepositoryDetail.jsx +++ b/frontend/src/pages/RepositoryDetail.jsx @@ -6,17 +6,17 @@ import { Database, Globe, Lock, + Search, Server, Shield, ShieldOff, Unlock, - Users, } from "lucide-react"; -import { useId, useState } from "react"; +import { useId, useMemo, useState } from "react"; -import { Link, useParams } from "react-router-dom"; -import { repositoryAPI } from "../utils/api"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { formatRelativeTime, repositoryAPI } from "../utils/api"; const RepositoryDetail = () => { const isActiveId = useId(); @@ -25,8 +25,12 @@ const RepositoryDetail = () => { const descriptionId = useId(); const { repositoryId } = useParams(); const queryClient = useQueryClient(); + const navigate = useNavigate(); const [editMode, setEditMode] = useState(false); const [formData, setFormData] = useState({}); + const [searchTerm, setSearchTerm] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(25); // Fetch repository details const { @@ -39,6 +43,49 @@ const RepositoryDetail = () => { enabled: !!repositoryId, }); + const hosts = repository?.host_repositories || []; + + // Filter and paginate hosts + const filteredAndPaginatedHosts = useMemo(() => { + let filtered = hosts; + + if (searchTerm) { + filtered = hosts.filter( + (hostRepo) => + hostRepo.hosts.friendly_name + ?.toLowerCase() + .includes(searchTerm.toLowerCase()) || + hostRepo.hosts.hostname + ?.toLowerCase() + .includes(searchTerm.toLowerCase()) || + hostRepo.hosts.ip?.toLowerCase().includes(searchTerm.toLowerCase()), + ); + } + + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + return filtered.slice(startIndex, endIndex); + }, [hosts, searchTerm, currentPage, pageSize]); + + const totalPages = Math.ceil( + (searchTerm + ? hosts.filter( + (hostRepo) => + hostRepo.hosts.friendly_name + ?.toLowerCase() + .includes(searchTerm.toLowerCase()) || + hostRepo.hosts.hostname + ?.toLowerCase() + .includes(searchTerm.toLowerCase()) || + hostRepo.hosts.ip?.toLowerCase().includes(searchTerm.toLowerCase()), + ).length + : hosts.length) / pageSize, + ); + + const handleHostClick = (hostId) => { + navigate(`/hosts/${hostId}`); + }; + // Update repository mutation const updateRepositoryMutation = useMutation({ mutationFn: (data) => repositoryAPI.update(repositoryId, data), @@ -157,9 +204,6 @@ const RepositoryDetail = () => { {repository.is_active ? "Active" : "Inactive"}
-

- Repository configuration and host assignments -

@@ -193,7 +237,7 @@ const RepositoryDetail = () => {
{/* Repository Information */} -
+

Repository Information @@ -369,80 +413,159 @@ const RepositoryDetail = () => {

{/* Hosts Using This Repository */} -
-
-

- - Hosts Using This Repository ( - {repository.host_repositories?.length || 0}) -

-
- {!repository.host_repositories || - repository.host_repositories.length === 0 ? ( -
- -

- No hosts using this repository -

-

- This repository hasn't been reported by any hosts yet. -

+
+
+
+
+ +

+ Hosts Using This Repository ({hosts.length}) +

+
- ) : ( -
- {repository.host_repositories.map((hostRepo) => ( -
-
-
-
-
- - {hostRepo.hosts.friendly_name} - -
- IP: {hostRepo.hosts.ip} - - OS: {hostRepo.hosts.os_type}{" "} - {hostRepo.hosts.os_version} - - - Last Update:{" "} - {new Date( - hostRepo.hosts.last_update, - ).toLocaleDateString()} - -
-
+ + {/* Search */} +
+ + { + setSearchTerm(e.target.value); + setCurrentPage(1); + }} + className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400" + /> +
+
+ +
+ {filteredAndPaginatedHosts.length === 0 ? ( +
+ +

+ {searchTerm + ? "No hosts match your search" + : "This repository hasn't been reported by any hosts yet."} +

+
+ ) : ( + <> + + + + + + + + + + + {filteredAndPaginatedHosts.map((hostRepo) => ( + handleHostClick(hostRepo.hosts.id)} + > + + + + + + ))} + +
+ Host + + Operating System + + Last Checked + + Last Update +
+
+
+ +
+
+ {hostRepo.hosts.friendly_name || + hostRepo.hosts.hostname} +
+ {hostRepo.hosts.friendly_name && + hostRepo.hosts.hostname && ( +
+ {hostRepo.hosts.hostname} +
+ )} +
+
+
+ {hostRepo.hosts.os_type} {hostRepo.hosts.os_version} + + {hostRepo.last_checked + ? formatRelativeTime(hostRepo.last_checked) + : "Never"} + + {hostRepo.hosts.last_update + ? formatRelativeTime(hostRepo.hosts.last_update) + : "Never"} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+
+ + Rows per page: + +
-
-
-
- Last Checked -
-
- {new Date(hostRepo.last_checked).toLocaleDateString()} -
-
+
+ + + Page {currentPage} of {totalPages} + +
-
- ))} -
- )} + )} + + )} +
); From 32ab004f3f8248df9e36221ed9cd4eebb824595a Mon Sep 17 00:00:00 2001 From: tigattack <10629864+tigattack@users.noreply.github.com> Date: Wed, 1 Oct 2025 01:14:30 +0100 Subject: [PATCH 31/51] feat: add repository deletion functionality with confirmation modal --- backend/src/routes/repositoryRoutes.js | 71 +++++++++++++++ frontend/src/pages/Repositories.jsx | 115 +++++++++++++++++++++--- frontend/src/pages/RepositoryDetail.jsx | 97 +++++++++++++++++++- frontend/src/utils/api.js | 1 + 4 files changed, 269 insertions(+), 15 deletions(-) diff --git a/backend/src/routes/repositoryRoutes.js b/backend/src/routes/repositoryRoutes.js index 6763d18..a1c0b10 100644 --- a/backend/src/routes/repositoryRoutes.js +++ b/backend/src/routes/repositoryRoutes.js @@ -289,6 +289,77 @@ router.get( }, ); +// Delete a specific repository (admin only) +router.delete( + "/:repositoryId", + authenticateToken, + requireManageHosts, + async (req, res) => { + try { + const { repositoryId } = req.params; + + // Check if repository exists first + const existingRepository = await prisma.repositories.findUnique({ + where: { id: repositoryId }, + select: { + id: true, + name: true, + url: true, + _count: { + select: { + host_repositories: true, + }, + }, + }, + }); + + if (!existingRepository) { + return res.status(404).json({ + error: "Repository not found", + details: "The repository may have been deleted or does not exist", + }); + } + + // Delete repository and all related data (cascade will handle host_repositories) + await prisma.repositories.delete({ + where: { id: repositoryId }, + }); + + res.json({ + message: "Repository deleted successfully", + deletedRepository: { + id: existingRepository.id, + name: existingRepository.name, + url: existingRepository.url, + hostCount: existingRepository._count.host_repositories, + }, + }); + } catch (error) { + console.error("Repository deletion error:", error); + + // Handle specific Prisma errors + if (error.code === "P2025") { + return res.status(404).json({ + error: "Repository not found", + details: "The repository may have been deleted or does not exist", + }); + } + + if (error.code === "P2003") { + return res.status(400).json({ + error: "Cannot delete repository due to foreign key constraints", + details: "The repository has related data that prevents deletion", + }); + } + + res.status(500).json({ + error: "Failed to delete repository", + details: error.message || "An unexpected error occurred", + }); + } + }, +); + // Cleanup orphaned repositories (admin only) router.delete( "/cleanup/orphaned", diff --git a/frontend/src/pages/Repositories.jsx b/frontend/src/pages/Repositories.jsx index 88b0fd5..812b2f0 100644 --- a/frontend/src/pages/Repositories.jsx +++ b/frontend/src/pages/Repositories.jsx @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, ArrowDown, @@ -7,7 +7,6 @@ import { Check, Columns, Database, - Eye, GripVertical, Lock, RefreshCw, @@ -15,20 +14,24 @@ import { Server, Shield, ShieldCheck, + Trash2, Unlock, X, } from "lucide-react"; import { useMemo, useState } from "react"; -import { Link } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { repositoryAPI } from "../utils/api"; const Repositories = () => { + const queryClient = useQueryClient(); + const navigate = useNavigate(); const [searchTerm, setSearchTerm] = useState(""); const [filterType, setFilterType] = useState("all"); // all, secure, insecure const [filterStatus, setFilterStatus] = useState("all"); // all, active, inactive const [sortField, setSortField] = useState("name"); const [sortDirection, setSortDirection] = useState("asc"); const [showColumnSettings, setShowColumnSettings] = useState(false); + const [deleteModalData, setDeleteModalData] = useState(null); // Column configuration const [columnConfig, setColumnConfig] = useState(() => { @@ -79,6 +82,15 @@ const Repositories = () => { queryFn: () => repositoryAPI.getStats().then((res) => res.data), }); + // Delete repository mutation + const deleteRepositoryMutation = useMutation({ + mutationFn: (repositoryId) => repositoryAPI.delete(repositoryId), + onSuccess: () => { + queryClient.invalidateQueries(["repositories"]); + queryClient.invalidateQueries(["repository-stats"]); + }, + }); + // Get visible columns in order const visibleColumns = columnConfig .filter((col) => col.visible) @@ -137,6 +149,32 @@ const Repositories = () => { updateColumnConfig(defaultConfig); }; + const handleDeleteRepository = (repo, e) => { + e.preventDefault(); + e.stopPropagation(); + + setDeleteModalData({ + id: repo.id, + name: repo.name, + hostCount: repo.hostCount || 0, + }); + }; + + const handleRowClick = (repo) => { + navigate(`/repositories/${repo.id}`); + }; + + const confirmDelete = () => { + if (deleteModalData) { + deleteRepositoryMutation.mutate(deleteModalData.id); + setDeleteModalData(null); + } + }; + + const cancelDelete = () => { + setDeleteModalData(null); + }; + // Filter and sort repositories const filteredAndSortedRepositories = useMemo(() => { if (!repositories) return []; @@ -224,6 +262,56 @@ const Repositories = () => { return (
+ {/* Delete Confirmation Modal */} + {deleteModalData && ( +
+
+
+ +

+ Delete Repository +

+
+
+

+ Are you sure you want to delete{" "} + "{deleteModalData.name}"? +

+ {deleteModalData.hostCount > 0 && ( +

+ ⚠️ This repository is currently assigned to{" "} + {deleteModalData.hostCount} host + {deleteModalData.hostCount !== 1 ? "s" : ""}. +

+ )} +

+ This action cannot be undone. +

+
+
+ + +
+
+
+ )} + {/* Page Header */}
@@ -414,7 +502,8 @@ const Repositories = () => { {filteredAndSortedRepositories.map((repo) => ( handleRowClick(repo)} > {visibleColumns.map((column) => ( { ); case "actions": return ( - - View - - +
+ +
); default: return null; diff --git a/frontend/src/pages/RepositoryDetail.jsx b/frontend/src/pages/RepositoryDetail.jsx index 8253565..a9edf9e 100644 --- a/frontend/src/pages/RepositoryDetail.jsx +++ b/frontend/src/pages/RepositoryDetail.jsx @@ -10,6 +10,7 @@ import { Server, Shield, ShieldOff, + Trash2, Unlock, } from "lucide-react"; @@ -24,13 +25,14 @@ const RepositoryDetail = () => { const priorityId = useId(); const descriptionId = useId(); const { repositoryId } = useParams(); - const queryClient = useQueryClient(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const [editMode, setEditMode] = useState(false); const [formData, setFormData] = useState({}); const [searchTerm, setSearchTerm] = useState(""); const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(25); + const [showDeleteModal, setShowDeleteModal] = useState(false); // Fetch repository details const { @@ -96,6 +98,15 @@ const RepositoryDetail = () => { }, }); + // Delete repository mutation + const deleteRepositoryMutation = useMutation({ + mutationFn: () => repositoryAPI.delete(repositoryId), + onSuccess: () => { + queryClient.invalidateQueries(["repositories"]); + navigate("/repositories"); + }, + }); + const handleEdit = () => { setFormData({ name: repository.name, @@ -115,6 +126,19 @@ const RepositoryDetail = () => { setFormData({}); }; + const handleDelete = () => { + setShowDeleteModal(true); + }; + + const confirmDelete = () => { + deleteRepositoryMutation.mutate(); + setShowDeleteModal(false); + }; + + const cancelDelete = () => { + setShowDeleteModal(false); + }; + if (isLoading) { return (
@@ -174,6 +198,56 @@ const RepositoryDetail = () => { return (
+ {/* Delete Confirmation Modal */} + {showDeleteModal && ( +
+
+
+ +

+ Delete Repository +

+
+
+

+ Are you sure you want to delete{" "} + "{repository?.name}"? +

+ {repository?.host_repositories?.length > 0 && ( +

+ ⚠️ This repository is currently assigned to{" "} + {repository.host_repositories.length} host + {repository.host_repositories.length !== 1 ? "s" : ""}. +

+ )} +

+ This action cannot be undone. +

+
+
+ + +
+
+
+ )} + {/* Header */}
@@ -229,9 +303,24 @@ const RepositoryDetail = () => { ) : ( - + <> + + + )}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 5fbfeaf..0dddd37 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -132,6 +132,7 @@ export const repositoryAPI = { getByHost: (hostId) => api.get(`/repositories/host/${hostId}`), update: (repositoryId, data) => api.put(`/repositories/${repositoryId}`, data), + delete: (repositoryId) => api.delete(`/repositories/${repositoryId}`), toggleHostRepository: (hostId, repositoryId, isEnabled) => api.patch(`/repositories/host/${hostId}/repository/${repositoryId}`, { isEnabled, From f9bd56215dd419c50f16edcd36590add150f6b85 Mon Sep 17 00:00:00 2001 From: 9 Technology Group LTD Date: Fri, 3 Oct 2025 22:10:41 +0100 Subject: [PATCH 32/51] Update README.md Changed the RoadMap URL --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 94dab7a..6e2c688 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Website](https://img.shields.io/badge/Website-patchmon.net-blue?style=for-the-badge)](https://patchmon.net) [![Discord](https://img.shields.io/badge/Discord-Join%20Server-blue?style=for-the-badge&logo=discord)](https://patchmon.net/discord) [![GitHub](https://img.shields.io/badge/GitHub-Repository-black?style=for-the-badge&logo=github)](https://github.com/9technologygroup/patchmon.net) -[![Roadmap](https://img.shields.io/badge/Roadmap-View%20Progress-green?style=for-the-badge&logo=github)](https://github.com/users/9technologygroup/projects/1) +[![Roadmap](https://img.shields.io/badge/Roadmap-View%20Progress-green?style=for-the-badge&logo=github)](https://github.com/orgs/PatchMon/projects/2) --- ## Please STAR this repo :D @@ -149,7 +149,7 @@ Operational ## Roadmap -- Roadmap board: https://github.com/users/9technologygroup/projects/1 +- Roadmap board: https://github.com/orgs/PatchMon/projects/2 ## License @@ -271,7 +271,7 @@ Thank you to all our contributors who help make PatchMon better every day! - **Website**: [patchmon.net](https://patchmon.net) - **Discord**: [https://patchmon.net/discord](https://patchmon.net/discord) -- **Roadmap**: [GitHub Projects](https://github.com/users/9technologygroup/projects/1) +- **Roadmap**: [GitHub Projects](https://github.com/orgs/PatchMon/projects/2) - **Documentation**: [Coming Soon] - **Support**: support@patchmon.net From 5e8cfa6b634d9405fc38eac9510a625b0811be98 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Fri, 3 Oct 2025 22:27:04 +0100 Subject: [PATCH 33/51] feat: Add Proxmox LXC auto-enrollment script with dpkg error recovery --- agents/proxmox_auto_enroll.sh | 4 ++-- backend/src/server.js | 36 ----------------------------------- 2 files changed, 2 insertions(+), 38 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index ad573a9..ff1202e 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -1,10 +1,10 @@ #!/bin/bash -set -euo pipefail # Exit on error, undefined vars, pipe failures +set -eo pipefail # Exit on error, pipe failures (removed -u as we handle unset vars explicitly) # Trap to catch errors only (not normal exits) trap 'echo "[ERROR] Script failed at line $LINENO with exit code $?"' ERR -SCRIPT_VERSION="1.1.0" +SCRIPT_VERSION="1.1.1" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= diff --git a/backend/src/server.js b/backend/src/server.js index e407f0a..6d83c2f 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -1,40 +1,4 @@ require("dotenv").config(); - -// Validate required environment variables on startup -function validateEnvironmentVariables() { - const requiredVars = { - JWT_SECRET: "Required for secure authentication token generation", - DATABASE_URL: "Required for database connection", - }; - - const missing = []; - - // Check required variables - for (const [varName, description] of Object.entries(requiredVars)) { - if (!process.env[varName]) { - missing.push(`${varName}: ${description}`); - } - } - - // Fail if required variables are missing - if (missing.length > 0) { - console.error("❌ Missing required environment variables:"); - for (const error of missing) { - console.error(` - ${error}`); - } - console.error(""); - console.error( - "Please set these environment variables and restart the application.", - ); - process.exit(1); - } - - console.log("✅ Environment variable validation passed"); -} - -// Validate environment variables before importing any modules that depend on them -validateEnvironmentVariables(); - const express = require("express"); const cors = require("cors"); const helmet = require("helmet"); From 657017801bad8f9a6e7895423075a57d70bc2ee4 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Fri, 3 Oct 2025 22:30:53 +0100 Subject: [PATCH 34/51] fix: Restore server.js from aa8b42c (accidentally overwrote with older version) --- backend/src/server.js | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/backend/src/server.js b/backend/src/server.js index 6d83c2f..e407f0a 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -1,4 +1,40 @@ require("dotenv").config(); + +// Validate required environment variables on startup +function validateEnvironmentVariables() { + const requiredVars = { + JWT_SECRET: "Required for secure authentication token generation", + DATABASE_URL: "Required for database connection", + }; + + const missing = []; + + // Check required variables + for (const [varName, description] of Object.entries(requiredVars)) { + if (!process.env[varName]) { + missing.push(`${varName}: ${description}`); + } + } + + // Fail if required variables are missing + if (missing.length > 0) { + console.error("❌ Missing required environment variables:"); + for (const error of missing) { + console.error(` - ${error}`); + } + console.error(""); + console.error( + "Please set these environment variables and restart the application.", + ); + process.exit(1); + } + + console.log("✅ Environment variable validation passed"); +} + +// Validate environment variables before importing any modules that depend on them +validateEnvironmentVariables(); + const express = require("express"); const cors = require("cors"); const helmet = require("helmet"); From 5c2bacb322d0ec2e56a932841cde25fd8f9cdcfe Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Fri, 3 Oct 2025 22:49:51 +0100 Subject: [PATCH 35/51] feat: Add failure details section showing last 5 lines of output for failed containers --- agents/proxmox_auto_enroll.sh | 39 ++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index ff1202e..d7f83e6 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -4,7 +4,7 @@ set -eo pipefail # Exit on error, pipe failures (removed -u as we handle unset # Trap to catch errors only (not normal exits) trap 'echo "[ERROR] Script failed at line $LINENO with exit code $?"' ERR -SCRIPT_VERSION="1.1.1" +SCRIPT_VERSION="1.2.0" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= @@ -115,6 +115,9 @@ failed_count=0 # Track containers with dpkg errors for later recovery declare -A dpkg_error_containers + +# Track all failed containers for summary +declare -A failed_containers info "Statistics initialized" # ===== PROCESS CONTAINERS ===== @@ -219,14 +222,20 @@ while IFS= read -r line; do elif [[ $install_exit_code -eq 124 ]]; then warn " ⏱ Agent installation timed out (>180s) in $friendly_name" info " Install output: $install_output" + # Store failure details + failed_containers["$vmid"]="$friendly_name|Timeout (>180s)|$install_output" ((failed_count++)) || true else # Check if it's a dpkg error if [[ "$install_output" == *"dpkg was interrupted"* ]] || [[ "$install_output" == *"dpkg --configure -a"* ]]; then warn " ⚠ Failed due to dpkg error in $friendly_name (can be fixed)" dpkg_error_containers["$vmid"]="$friendly_name:$api_id:$api_key" + # Store failure details + failed_containers["$vmid"]="$friendly_name|dpkg error|$install_output" else warn " ✗ Failed to install agent in $friendly_name (exit: $install_exit_code)" + # Store failure details + failed_containers["$vmid"]="$friendly_name|Exit code $install_exit_code|$install_output" fi info " Install output: $install_output" ((failed_count++)) || true @@ -237,10 +246,12 @@ while IFS= read -r line; do ((skipped_count++)) || true elif [[ "$http_code" == "429" ]]; then error " ✗ Rate limit exceeded - maximum hosts per day reached" + failed_containers["$vmid"]="$friendly_name|Rate limit exceeded|$body" ((failed_count++)) || true else error " ✗ Failed to enroll $friendly_name - HTTP $http_code" debug " Response: $body" + failed_containers["$vmid"]="$friendly_name|HTTP $http_code enrollment failed|$body" ((failed_count++)) || true fi @@ -261,6 +272,32 @@ info "Skipped: $skipped_count" info "Failed: $failed_count" echo "" +# ===== FAILURE DETAILS ===== +if [[ ${#failed_containers[@]} -gt 0 ]]; then + echo "╔═══════════════════════════════════════════════════════════════╗" + echo "║ FAILURE DETAILS ║" + echo "╚═══════════════════════════════════════════════════════════════╝" + echo "" + + for vmid in "${!failed_containers[@]}"; do + IFS='|' read -r name reason output <<< "${failed_containers[$vmid]}" + + warn "Container $vmid: $name" + info " Reason: $reason" + info " Last 5 lines of output:" + + # Get last 5 lines of output + last_5_lines=$(echo "$output" | tail -n 5) + + # Display each line with proper indentation + while IFS= read -r line; do + echo " $line" + done <<< "$last_5_lines" + + echo "" + done +fi + if [[ "$DRY_RUN" == "true" ]]; then warn "This was a DRY RUN - no actual changes were made" warn "Set DRY_RUN=false to perform actual enrollment" From f7b73ba280d65487bbd0a22c23b361b6287726e2 Mon Sep 17 00:00:00 2001 From: 9 Technology Group LTD Date: Fri, 3 Oct 2025 23:26:46 +0100 Subject: [PATCH 36/51] Update app_build.yml --- .github/workflows/app_build.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/app_build.yml b/.github/workflows/app_build.yml index 70fc285..182a201 100644 --- a/.github/workflows/app_build.yml +++ b/.github/workflows/app_build.yml @@ -1,11 +1,9 @@ name: Build on Merge - on: push: branches: - main - dev - jobs: deploy: runs-on: self-hosted @@ -15,3 +13,11 @@ jobs: - name: Run rebuild script run: /root/patchmon/platform/scripts/app_build.sh ${{ github.ref_name }} + + rebuild-pmon: + runs-on: self-hosted + needs: deploy + if: github.ref_name == 'dev' + steps: + - name: Rebuild pmon + run: /root/patchmon/platform/scripts/manage_pmon_auto.sh From b3887c818d24899eb98dd6af410d89222777c6d6 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Fri, 3 Oct 2025 23:39:58 +0100 Subject: [PATCH 37/51] chore: Update GitHub repository URLs from 9technologygroup/patchmon.net to PatchMon/PatchMon --- README.md | 10 +++++----- frontend/src/components/Layout.jsx | 2 +- setup.sh | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6e2c688..3b23710 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Website](https://img.shields.io/badge/Website-patchmon.net-blue?style=for-the-badge)](https://patchmon.net) [![Discord](https://img.shields.io/badge/Discord-Join%20Server-blue?style=for-the-badge&logo=discord)](https://patchmon.net/discord) -[![GitHub](https://img.shields.io/badge/GitHub-Repository-black?style=for-the-badge&logo=github)](https://github.com/9technologygroup/patchmon.net) +[![GitHub](https://img.shields.io/badge/GitHub-Repository-black?style=for-the-badge&logo=github)](https://github.com/PatchMon/PatchMon) [![Roadmap](https://img.shields.io/badge/Roadmap-View%20Progress-green?style=for-the-badge&logo=github)](https://github.com/orgs/PatchMon/projects/2) --- @@ -12,7 +12,7 @@ PatchMon provides centralized patch management across diverse server environments. Agents communicate outbound-only to the PatchMon server, eliminating inbound ports on monitored hosts while delivering comprehensive visibility and safe automation. -![Dashboard Screenshot](https://raw.githubusercontent.com/9technologygroup/patchmon.net/main/dashboard.jpeg) +![Dashboard Screenshot](https://raw.githubusercontent.com/PatchMon/PatchMon/main/dashboard.jpeg) ## Features @@ -63,7 +63,7 @@ Managed, zero-maintenance PatchMon hosting. Stay tuned. #### Docker (preferred) -For getting started with Docker, see the [Docker documentation](https://github.com/9technologygroup/patchmon.net/blob/main/docker/README.md) +For getting started with Docker, see the [Docker documentation](https://github.com/PatchMon/PatchMon/blob/main/docker/README.md) #### Native Install (advanced/non-docker) @@ -85,7 +85,7 @@ apt install curl -y #### Script ```bash -curl -fsSL -o setup.sh https://raw.githubusercontent.com/9technologygroup/patchmon.net/refs/heads/main/setup.sh && chmod +x setup.sh && bash setup.sh +curl -fsSL -o setup.sh https://raw.githubusercontent.com/PatchMon/PatchMon/refs/heads/main/setup.sh && chmod +x setup.sh && bash setup.sh ``` #### Minimum specs for building : ##### @@ -282,6 +282,6 @@ Thank you to all our contributors who help make PatchMon better every day! **Made with ❤️ by the PatchMon Team** [![Discord](https://img.shields.io/badge/Discord-Join%20Server-blue?style=for-the-badge&logo=discord)](https://patchmon.net/discord) -[![GitHub](https://img.shields.io/badge/GitHub-Repository-black?style=for-the-badge&logo=github)](https://github.com/9technologygroup/patchmon.net) +[![GitHub](https://img.shields.io/badge/GitHub-Repository-black?style=for-the-badge&logo=github)](https://github.com/PatchMon/PatchMon)
diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 4bb70a9..8541f2f 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -882,7 +882,7 @@ const Layout = ({ children }) => { {/* External Links */}
Date: Fri, 3 Oct 2025 23:57:38 +0100 Subject: [PATCH 38/51] feat: Auto-install curl in LXC containers if missing before agent installation --- agents/proxmox_auto_enroll.sh | 40 ++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index d7f83e6..6b5b613 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -4,7 +4,7 @@ set -eo pipefail # Exit on error, pipe failures (removed -u as we handle unset # Trap to catch errors only (not normal exits) trap 'echo "[ERROR] Script failed at line $LINENO with exit code $?"' ERR -SCRIPT_VERSION="1.2.0" +SCRIPT_VERSION="1.3.0" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= @@ -197,6 +197,44 @@ while IFS= read -r line; do info " ✓ Host enrolled successfully: $api_id" + # Ensure curl is installed in the container + info " Checking for curl in container..." + curl_check=$(timeout 10 pct exec "$vmid" -- bash -c "command -v curl >/dev/null 2>&1 && echo 'installed' || echo 'missing'" 2>/dev/null /dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq && apt-get install -y -qq curl + elif command -v yum >/dev/null 2>&1; then + yum install -y -q curl + elif command -v dnf >/dev/null 2>&1; then + dnf install -y -q curl + elif command -v apk >/dev/null 2>&1; then + apk add --no-cache curl + else + echo 'ERROR: No supported package manager found' + exit 1 + fi + " 2>&1 Date: Sat, 4 Oct 2025 09:02:56 +0100 Subject: [PATCH 39/51] feat: Implement machine_id based host identification - Add machine_id field to hosts schema (unique, indexed) - Remove unique constraint from friendly_name (allow duplicate hostnames) - Agent installer now generates/reads persistent machine_id - Proxmox script retrieves machine_id from LXC containers - Backend will check machine_id instead of hostname for duplicates This allows multiple hosts with same hostname to coexist in PatchMon --- agents/patchmon_install.sh | 45 +++++++++++++++++++++++++++++++++++ agents/proxmox_auto_enroll.sh | 7 +++++- backend/prisma/schema.prisma | 7 +++++- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/agents/patchmon_install.sh b/agents/patchmon_install.sh index 0950e37..1dd4944 100644 --- a/agents/patchmon_install.sh +++ b/agents/patchmon_install.sh @@ -109,14 +109,32 @@ cleanup_old_files() { # Run cleanup at start cleanup_old_files +# Generate or retrieve machine ID +get_machine_id() { + # Try multiple sources for machine ID + if [[ -f /etc/machine-id ]]; then + cat /etc/machine-id + elif [[ -f /var/lib/dbus/machine-id ]]; then + cat /var/lib/dbus/machine-id + else + # Fallback: generate from hardware info (less ideal but works) + echo "patchmon-$(cat /sys/class/dmi/id/product_uuid 2>/dev/null || cat /proc/sys/kernel/random/uuid)" + fi +} + # Parse arguments from environment (passed via HTTP headers) if [[ -z "$PATCHMON_URL" ]] || [[ -z "$API_ID" ]] || [[ -z "$API_KEY" ]]; then error "Missing required parameters. This script should be called via the PatchMon web interface." fi +# Get unique machine ID for this host +MACHINE_ID=$(get_machine_id) +export MACHINE_ID + info "🚀 Starting PatchMon Agent Installation..." info "📋 Server: $PATCHMON_URL" info "🔑 API ID: ${API_ID:0:16}..." +info "🆔 Machine ID: ${MACHINE_ID:0:16}..." # Display diagnostic information echo "" @@ -261,6 +279,33 @@ if [[ -f "/var/log/patchmon-agent.log" ]]; then fi # Step 4: Test the configuration +# Check if this machine is already enrolled +info "🔍 Checking if machine is already enrolled..." +existing_check=$(curl $CURL_FLAGS -s -X POST \ + -H "X-API-ID: $API_ID" \ + -H "X-API-KEY: $API_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"machine_id\": \"$MACHINE_ID\"}" \ + "$PATCHMON_URL/api/v1/hosts/check-machine-id" \ + -w "\n%{http_code}" 2>&1) + +http_code=$(echo "$existing_check" | tail -n 1) +response_body=$(echo "$existing_check" | sed '$d') + +if [[ "$http_code" == "200" ]]; then + already_enrolled=$(echo "$response_body" | jq -r '.exists' 2>/dev/null || echo "false") + if [[ "$already_enrolled" == "true" ]]; then + warning "⚠️ This machine is already enrolled in PatchMon" + info "Machine ID: $MACHINE_ID" + info "Existing host: $(echo "$response_body" | jq -r '.host.friendly_name' 2>/dev/null)" + info "" + info "The agent will be reinstalled/updated with existing credentials." + echo "" + else + success "✅ Machine not yet enrolled - proceeding with installation" + fi +fi + info "🧪 Testing API credentials and connectivity..." if /usr/local/bin/patchmon-agent.sh test; then success "✅ TEST: API credentials are valid and server is reachable" diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 6b5b613..ebfb20f 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -4,7 +4,7 @@ set -eo pipefail # Exit on error, pipe failures (removed -u as we handle unset # Trap to catch errors only (not normal exits) trap 'echo "[ERROR] Script failed at line $LINENO with exit code $?"' ERR -SCRIPT_VERSION="1.3.0" +SCRIPT_VERSION="2.0.0" echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))" # ============================================================================= @@ -151,12 +151,16 @@ while IFS= read -r line; do hostname=$(timeout 5 pct exec "$vmid" -- hostname 2>/dev/null /dev/null /dev/null /dev/null || cat /var/lib/dbus/machine-id 2>/dev/null || echo 'proxmox-lxc-$vmid-'$(cat /proc/sys/kernel/random/uuid)" /dev/null || echo "proxmox-lxc-$vmid-unknown") friendly_name="${HOST_PREFIX}${hostname}" info " Hostname: $hostname" info " IP Address: $ip_address" info " OS: $os_info" + info " Machine ID: ${machine_id:0:16}..." if [[ "$DRY_RUN" == "true" ]]; then info " [DRY RUN] Would enroll: $friendly_name" @@ -174,6 +178,7 @@ while IFS= read -r line; do -H "Content-Type: application/json" \ -d "{ \"friendly_name\": \"$friendly_name\", + \"machine_id\": \"$machine_id\", \"metadata\": { \"vmid\": \"$vmid\", \"proxmox_node\": \"$(hostname)\", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5db7dcb..adca449 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -60,7 +60,8 @@ model host_repositories { model hosts { id String @id - friendly_name String @unique + machine_id String @unique + friendly_name String ip String? os_type String os_version String @@ -92,6 +93,10 @@ model hosts { host_repositories host_repositories[] host_groups host_groups? @relation(fields: [host_group_id], references: [id]) update_history update_history[] + + @@index([machine_id]) + @@index([friendly_name]) + @@index([hostname]) } model packages { From b25bba50a7e54aeb96328297105ba9193783347b Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Sat, 4 Oct 2025 09:04:35 +0100 Subject: [PATCH 40/51] feat(backend): Update routes to use machine_id for host identification - Auto-enrollment endpoints now require and validate machine_id - Check for duplicates by machine_id instead of friendly_name - Added /hosts/check-machine-id endpoint for agent installer - Bulk enrollment updated to handle machine_id - Multiple hosts with same hostname now supported --- backend/src/routes/autoEnrollmentRoutes.js | 33 ++++++++++++----- backend/src/routes/hostRoutes.js | 42 ++++++++++++++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/backend/src/routes/autoEnrollmentRoutes.js b/backend/src/routes/autoEnrollmentRoutes.js index e475d56..af7854a 100644 --- a/backend/src/routes/autoEnrollmentRoutes.js +++ b/backend/src/routes/autoEnrollmentRoutes.js @@ -521,6 +521,9 @@ router.post( body("friendly_name") .isLength({ min: 1, max: 255 }) .withMessage("Friendly name is required"), + body("machine_id") + .isLength({ min: 1, max: 255 }) + .withMessage("Machine ID is required"), body("metadata").optional().isObject(), ], async (req, res) => { @@ -530,15 +533,15 @@ router.post( return res.status(400).json({ errors: errors.array() }); } - const { friendly_name } = req.body; + const { friendly_name, machine_id } = req.body; // Generate host API credentials const api_id = `patchmon_${crypto.randomBytes(8).toString("hex")}`; const api_key = crypto.randomBytes(32).toString("hex"); - // Check if host already exists + // Check if host already exists by machine_id (not hostname) const existing_host = await prisma.hosts.findUnique({ - where: { friendly_name }, + where: { machine_id }, }); if (existing_host) { @@ -546,7 +549,10 @@ router.post( error: "Host already exists", host_id: existing_host.id, api_id: existing_host.api_id, - message: "This host is already enrolled in PatchMon", + machine_id: existing_host.machine_id, + friendly_name: existing_host.friendly_name, + message: + "This machine is already enrolled in PatchMon (matched by machine ID)", }); } @@ -554,6 +560,7 @@ router.post( const host = await prisma.hosts.create({ data: { id: uuidv4(), + machine_id, friendly_name, os_type: "unknown", os_version: "unknown", @@ -648,17 +655,26 @@ router.post( for (const host_data of hosts) { try { - const { friendly_name } = host_data; + const { friendly_name, machine_id } = host_data; - // Check if host already exists + if (!machine_id) { + results.failed.push({ + friendly_name, + error: "Machine ID is required", + }); + continue; + } + + // Check if host already exists by machine_id const existing_host = await prisma.hosts.findUnique({ - where: { friendly_name }, + where: { machine_id }, }); if (existing_host) { results.skipped.push({ friendly_name, - reason: "Already exists", + machine_id, + reason: "Machine already enrolled", api_id: existing_host.api_id, }); continue; @@ -672,6 +688,7 @@ router.post( const host = await prisma.hosts.create({ data: { id: uuidv4(), + machine_id, friendly_name, os_type: "unknown", os_version: "unknown", diff --git a/backend/src/routes/hostRoutes.js b/backend/src/routes/hostRoutes.js index 842f286..8f23fcc 100644 --- a/backend/src/routes/hostRoutes.js +++ b/backend/src/routes/hostRoutes.js @@ -1151,6 +1151,48 @@ export CURL_FLAGS="${curlFlags}" } }); +// Check if machine_id already exists (requires auth) +router.post("/check-machine-id", validateApiCredentials, async (req, res) => { + try { + const { machine_id } = req.body; + + if (!machine_id) { + return res.status(400).json({ + error: "machine_id is required", + }); + } + + // Check if a host with this machine_id exists + const existing_host = await prisma.hosts.findUnique({ + where: { machine_id }, + select: { + id: true, + friendly_name: true, + machine_id: true, + api_id: true, + status: true, + created_at: true, + }, + }); + + if (existing_host) { + return res.status(200).json({ + exists: true, + host: existing_host, + message: "This machine is already enrolled", + }); + } + + return res.status(200).json({ + exists: false, + message: "Machine not yet enrolled", + }); + } catch (error) { + console.error("Error checking machine_id:", error); + res.status(500).json({ error: "Failed to check machine_id" }); + } +}); + // Serve the removal script (public endpoint - no authentication required) router.get("/remove", async (_req, res) => { try { From 3cf2ada84e82cbcbb41a466576ef2ca9abaa0deb Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Sat, 4 Oct 2025 09:05:36 +0100 Subject: [PATCH 41/51] migration: Add machine_id column to hosts table - Adds machine_id as unique identifier for hosts - Migrates existing hosts with 'migrated-' prefix - Removes unique constraint from friendly_name - Adds indexes for performance --- .../migration.sql | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 backend/prisma/migrations/20251004090459_add_machine_id_to_hosts/migration.sql diff --git a/backend/prisma/migrations/20251004090459_add_machine_id_to_hosts/migration.sql b/backend/prisma/migrations/20251004090459_add_machine_id_to_hosts/migration.sql new file mode 100644 index 0000000..026a0fa --- /dev/null +++ b/backend/prisma/migrations/20251004090459_add_machine_id_to_hosts/migration.sql @@ -0,0 +1,17 @@ +-- Add machine_id column as nullable first +ALTER TABLE "hosts" ADD COLUMN "machine_id" TEXT; + +-- Generate machine_ids for existing hosts using their API ID as a fallback +UPDATE "hosts" SET "machine_id" = 'migrated-' || "api_id" WHERE "machine_id" IS NULL; + +-- Remove the unique constraint from friendly_name +ALTER TABLE "hosts" DROP CONSTRAINT IF EXISTS "hosts_friendly_name_key"; + +-- Now make machine_id NOT NULL and add unique constraint +ALTER TABLE "hosts" ALTER COLUMN "machine_id" SET NOT NULL; +ALTER TABLE "hosts" ADD CONSTRAINT "hosts_machine_id_key" UNIQUE ("machine_id"); + +-- Create indexes for better query performance +CREATE INDEX "hosts_machine_id_idx" ON "hosts"("machine_id"); +CREATE INDEX "hosts_friendly_name_idx" ON "hosts"("friendly_name"); + From 35d3c28ae5a0c6812595d7d1dd506fa9dc326dde Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Sat, 4 Oct 2025 09:11:47 +0100 Subject: [PATCH 42/51] feat(ui): Display machine_id in host details page and enable search - Added machine_id field to host details page - Backend now returns machine_id in all host queries - Users can search hosts by machine_id - Added hostname index to schema for better performance --- backend/src/routes/dashboardRoutes.js | 1 + backend/src/routes/searchRoutes.js | 2 ++ frontend/src/pages/HostDetail.jsx | 11 +++++++++++ 3 files changed, 14 insertions(+) diff --git a/backend/src/routes/dashboardRoutes.js b/backend/src/routes/dashboardRoutes.js index fb54447..5169873 100644 --- a/backend/src/routes/dashboardRoutes.js +++ b/backend/src/routes/dashboardRoutes.js @@ -185,6 +185,7 @@ router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => { // Show all hosts regardless of status select: { id: true, + machine_id: true, friendly_name: true, hostname: true, ip: true, diff --git a/backend/src/routes/searchRoutes.js b/backend/src/routes/searchRoutes.js index 077f780..456dde4 100644 --- a/backend/src/routes/searchRoutes.js +++ b/backend/src/routes/searchRoutes.js @@ -70,10 +70,12 @@ router.get("/", authenticateToken, async (req, res) => { { hostname: { contains: searchTerm, mode: "insensitive" } }, { friendly_name: { contains: searchTerm, mode: "insensitive" } }, { ip: { contains: searchTerm, mode: "insensitive" } }, + { machine_id: { contains: searchTerm, mode: "insensitive" } }, ], }, select: { id: true, + machine_id: true, hostname: true, friendly_name: true, ip: true, diff --git a/frontend/src/pages/HostDetail.jsx b/frontend/src/pages/HostDetail.jsx index 8f2b222..4a9e785 100644 --- a/frontend/src/pages/HostDetail.jsx +++ b/frontend/src/pages/HostDetail.jsx @@ -387,6 +387,17 @@ const HostDetail = () => {
)} + {host.machine_id && ( +
+

+ Machine ID +

+

+ {host.machine_id} +

+
+ )} +

Host Group From dd28e741d407e9328c54868089b0bf4a8445e529 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Sat, 4 Oct 2025 09:39:47 +0100 Subject: [PATCH 43/51] fix: manual host creation and improve host identification - Add machine_id support for manual host creation from GUI - Generate temporary 'pending-{uuid}' machine_id for new hosts - Agent now collects and sends machine_id on every update - Backend replaces pending machine_id with real one on first agent connection - Remove unnecessary duplicate name check (friendly_name can be duplicated) - Add get_machine_id() function to agent (reads from /etc/machine-id, /var/lib/dbus/machine-id, or generates fallback) - Display IP address in Network tab on host details page - Fix network tab visibility conditions to include host.ip This ensures proper host identification using machine_id while maintaining backwards compatibility with API credentials as the primary authentication method. --- agents/patchmon-agent.sh | 28 +++++++++++++++++++++++++++- backend/src/routes/hostRoutes.js | 19 ++++++++++--------- frontend/src/pages/HostDetail.jsx | 15 ++++++++++++++- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/agents/patchmon-agent.sh b/agents/patchmon-agent.sh index 843e21d..679c55d 100755 --- a/agents/patchmon-agent.sh +++ b/agents/patchmon-agent.sh @@ -56,6 +56,28 @@ warning() { log "WARNING: $1" } +# Get or generate machine ID +get_machine_id() { + # Try standard locations for machine-id + if [[ -f /etc/machine-id ]]; then + cat /etc/machine-id + elif [[ -f /var/lib/dbus/machine-id ]]; then + cat /var/lib/dbus/machine-id + else + # Fallback: generate from hardware UUID or hostname+MAC + if command -v dmidecode &> /dev/null; then + local uuid=$(dmidecode -s system-uuid 2>/dev/null | tr -d ' -' | tr '[:upper:]' '[:lower:]') + if [[ -n "$uuid" && "$uuid" != "notpresent" ]]; then + echo "$uuid" + return + fi + fi + # Last resort: hash hostname + primary MAC address + local primary_mac=$(ip link show | grep -oP '(?<=link/ether\s)[0-9a-f:]+' | head -1 | tr -d ':') + echo "$HOSTNAME-$primary_mac" | sha256sum | cut -d' ' -f1 | cut -c1-32 + fi +} + # Check if running as root check_root() { if [[ $EUID -ne 0 ]]; then @@ -865,6 +887,9 @@ send_update() { # Merge all JSON objects into one local merged_json=$(echo "$hardware_json $network_json $system_json" | jq -s '.[0] * .[1] * .[2]') + # Get machine ID + local machine_id=$(get_machine_id) + # Create the base payload and merge with system info local base_payload=$(cat < { try { @@ -338,6 +334,11 @@ router.post( updated_at: new Date(), }; + // Update machine_id if provided and current one is a placeholder + if (req.body.machineId && host.machine_id.startsWith("pending-")) { + updateData.machine_id = req.body.machineId; + } + // Basic system info if (req.body.osType) updateData.os_type = req.body.osType; if (req.body.osVersion) updateData.os_version = req.body.osVersion; diff --git a/frontend/src/pages/HostDetail.jsx b/frontend/src/pages/HostDetail.jsx index 4a9e785..8d185ac 100644 --- a/frontend/src/pages/HostDetail.jsx +++ b/frontend/src/pages/HostDetail.jsx @@ -466,11 +466,23 @@ const HostDetail = () => { {/* Network Information */} {activeTab === "network" && - (host.gateway_ip || + (host.ip || + host.gateway_ip || host.dns_servers || host.network_interfaces) && (

+ {host.ip && ( +
+

+ IP Address +

+

+ {host.ip} +

+
+ )} + {host.gateway_ip && (

@@ -802,6 +814,7 @@ const HostDetail = () => { {activeTab === "network" && !( + host.ip || host.gateway_ip || host.dns_servers || host.network_interfaces From 3a76d54707f5638aa7c0d081bf972e48d94d957b Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Sat, 4 Oct 2025 09:44:18 +0100 Subject: [PATCH 44/51] Made Proxmox LXC a tab within integrations page --- frontend/src/pages/settings/Integrations.jsx | 394 ++++++++++--------- 1 file changed, 216 insertions(+), 178 deletions(-) diff --git a/frontend/src/pages/settings/Integrations.jsx b/frontend/src/pages/settings/Integrations.jsx index ade539c..0887350 100644 --- a/frontend/src/pages/settings/Integrations.jsx +++ b/frontend/src/pages/settings/Integrations.jsx @@ -14,6 +14,7 @@ import SettingsLayout from "../../components/SettingsLayout"; import api from "../../utils/api"; const Integrations = () => { + const [activeTab, setActiveTab] = useState("proxmox"); const [tokens, setTokens] = useState([]); const [host_groups, setHostGroups] = useState([]); const [loading, setLoading] = useState(true); @@ -33,6 +34,10 @@ const Integrations = () => { const [copy_success, setCopySuccess] = useState({}); + const handleTabChange = (tabName) => { + setActiveTab(tabName); + }; + // biome-ignore lint/correctness/useExhaustiveDependencies: Only run on mount useEffect(() => { load_tokens(); @@ -163,193 +168,226 @@ const Integrations = () => {

{/* Header */} -
-
-

- Integrations -

-

- Manage auto-enrollment tokens for Proxmox and other integrations -

-
- +
+

+ Integrations +

+

+ Manage auto-enrollment tokens for Proxmox and other integrations +

- {/* Proxmox Integration Section */} -
-
-
- -
-
-

- Proxmox LXC Auto-Enrollment -

-

- Automatically discover and enroll LXC containers from Proxmox - hosts -

-
+ {/* Tabs Navigation */} +
+
+ + {/* Future tabs can be added here */}
- {/* Token List */} - {loading ? ( -
-
-
- ) : tokens.length === 0 ? ( -
-

No auto-enrollment tokens created yet.

-

- Create a token to enable automatic host enrollment from Proxmox. -

-
- ) : ( -
- {tokens.map((token) => ( -
-
-
-
-

- {token.token_name} -

- - Proxmox LXC - - {token.is_active ? ( - - Active - - ) : ( - - Inactive - - )} -
-
-
- - {token.token_key} - - -
-

- Usage: {token.hosts_created_today}/ - {token.max_hosts_per_day} hosts today -

- {token.host_groups && ( -

- Default Group:{" "} - - {token.host_groups.name} - -

- )} - {token.allowed_ip_ranges?.length > 0 && ( -

- Allowed IPs: {token.allowed_ip_ranges.join(", ")} -

- )} -

Created: {format_date(token.created_at)}

- {token.last_used_at && ( -

Last Used: {format_date(token.last_used_at)}

- )} - {token.expires_at && ( -

- Expires: {format_date(token.expires_at)} - {new Date(token.expires_at) < new Date() && ( - - (Expired) - - )} -

- )} -
+ {/* Tab Content */} +
+ {/* Proxmox Tab */} + {activeTab === "proxmox" && ( +
+ {/* Header with New Token Button */} +
+
+
+
-
- - +
+

+ Proxmox LXC Auto-Enrollment +

+

+ Automatically discover and enroll LXC containers from + Proxmox hosts +

+
- ))} -
- )} -
- {/* Documentation Section */} -
-

- How to Use Auto-Enrollment -

-
    -
  1. Create a new auto-enrollment token using the button above
  2. -
  3. - Copy the one-line installation command shown in the success dialog -
  4. -
  5. SSH into your Proxmox host as root
  6. -
  7. - Paste and run the command - it will automatically discover and - enroll all running LXC containers -
  8. -
  9. View enrolled containers in the Hosts page
  10. -
-
-

- 💡 Tip: You can run the same command multiple - times safely - already enrolled containers will be automatically - skipped. -

+ {/* Token List */} + {loading ? ( +
+
+
+ ) : tokens.length === 0 ? ( +
+

No auto-enrollment tokens created yet.

+

+ Create a token to enable automatic host enrollment from + Proxmox. +

+
+ ) : ( +
+ {tokens.map((token) => ( +
+
+
+
+

+ {token.token_name} +

+ + Proxmox LXC + + {token.is_active ? ( + + Active + + ) : ( + + Inactive + + )} +
+
+
+ + {token.token_key} + + +
+

+ Usage: {token.hosts_created_today}/ + {token.max_hosts_per_day} hosts today +

+ {token.host_groups && ( +

+ Default Group:{" "} + + {token.host_groups.name} + +

+ )} + {token.allowed_ip_ranges?.length > 0 && ( +

+ Allowed IPs:{" "} + {token.allowed_ip_ranges.join(", ")} +

+ )} +

Created: {format_date(token.created_at)}

+ {token.last_used_at && ( +

+ Last Used: {format_date(token.last_used_at)} +

+ )} + {token.expires_at && ( +

+ Expires: {format_date(token.expires_at)} + {new Date(token.expires_at) < new Date() && ( + + (Expired) + + )} +

+ )} +
+
+
+ + +
+
+
+ ))} +
+ )} + + {/* Documentation Section */} +
+

+ How to Use Auto-Enrollment +

+
    +
  1. + Create a new auto-enrollment token using the button above +
  2. +
  3. + Copy the one-line installation command shown in the + success dialog +
  4. +
  5. SSH into your Proxmox host as root
  6. +
  7. + Paste and run the command - it will automatically discover + and enroll all running LXC containers +
  8. +
  9. View enrolled containers in the Hosts page
  10. +
+
+

+ 💡 Tip: You can run the same command + multiple times safely - already enrolled containers will + be automatically skipped. +

+
+
+
+ )}
From 766d36ff80622a036ecc1feefeed1aa5618c3468 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Sat, 4 Oct 2025 10:44:06 +0100 Subject: [PATCH 45/51] fix: migration to properly drop unique index on friendly_name The migration was dropping the constraint but not the underlying unique index. In PostgreSQL, unique constraints and unique indexes can exist independently. This caused auto-enrollment to fail with 'unique constraint violated' errors. Added explicit DROP INDEX statement to ensure the unique index is removed, allowing duplicate friendly_name values while machine_id remains unique. --- .../20251004090459_add_machine_id_to_hosts/migration.sql | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/prisma/migrations/20251004090459_add_machine_id_to_hosts/migration.sql b/backend/prisma/migrations/20251004090459_add_machine_id_to_hosts/migration.sql index 026a0fa..035422b 100644 --- a/backend/prisma/migrations/20251004090459_add_machine_id_to_hosts/migration.sql +++ b/backend/prisma/migrations/20251004090459_add_machine_id_to_hosts/migration.sql @@ -7,6 +7,9 @@ UPDATE "hosts" SET "machine_id" = 'migrated-' || "api_id" WHERE "machine_id" IS -- Remove the unique constraint from friendly_name ALTER TABLE "hosts" DROP CONSTRAINT IF EXISTS "hosts_friendly_name_key"; +-- Also drop the unique index if it exists (constraint and index can exist separately) +DROP INDEX IF EXISTS "hosts_friendly_name_key"; + -- Now make machine_id NOT NULL and add unique constraint ALTER TABLE "hosts" ALTER COLUMN "machine_id" SET NOT NULL; ALTER TABLE "hosts" ADD CONSTRAINT "hosts_machine_id_key" UNIQUE ("machine_id"); From fa57b352700f253fb08ea9ce7b398f74ea0a00ae Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Sat, 4 Oct 2025 13:09:29 +0100 Subject: [PATCH 46/51] Added /hosts/install?force=true to the api endpoint to force the installation of the agent if there are existing broken packages on the host you want to monito --- README.md | 4 -- agents/patchmon_install.sh | 83 +++++++++++++++++++++++++++++++- backend/src/routes/hostRoutes.js | 6 ++- 3 files changed, 86 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3b23710..70202f0 100644 --- a/README.md +++ b/README.md @@ -136,10 +136,6 @@ Agents (Outbound Only) | Agents on your servers | ----------> | Backend API (/api/v1) | +---------------------------+ +------------------------+ -Operational -- systemd manages backend service -- certbot/nginx for TLS (public) -- setup.sh bootstraps OS, app, DB, config ``` ## Support diff --git a/agents/patchmon_install.sh b/agents/patchmon_install.sh index 1dd4944..c6b3594 100644 --- a/agents/patchmon_install.sh +++ b/agents/patchmon_install.sh @@ -127,6 +127,13 @@ if [[ -z "$PATCHMON_URL" ]] || [[ -z "$API_ID" ]] || [[ -z "$API_KEY" ]]; then error "Missing required parameters. This script should be called via the PatchMon web interface." fi +# Check if --force flag is set (for bypassing broken packages) +FORCE_INSTALL="${FORCE_INSTALL:-false}" +if [[ "$*" == *"--force"* ]] || [[ "$FORCE_INSTALL" == "true" ]]; then + FORCE_INSTALL="true" + warning "⚠️ Force mode enabled - will bypass broken packages" +fi + # Get unique machine ID for this host MACHINE_ID=$(get_machine_id) export MACHINE_ID @@ -149,16 +156,88 @@ echo "" info "📦 Installing required dependencies..." echo "" +# Function to check if a command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Function to install packages with error handling +install_apt_packages() { + local packages=("$@") + local missing_packages=() + + # Check which packages are missing + for pkg in "${packages[@]}"; do + if ! command_exists "$pkg"; then + missing_packages+=("$pkg") + fi + done + + if [ ${#missing_packages[@]} -eq 0 ]; then + success "All required packages are already installed" + return 0 + fi + + info "Need to install: ${missing_packages[*]}" + + # Build apt-get command based on force mode + local apt_cmd="apt-get install ${missing_packages[*]} -y" + + if [[ "$FORCE_INSTALL" == "true" ]]; then + info "Using force mode - bypassing broken packages..." + apt_cmd="$apt_cmd -o APT::Get::Fix-Broken=false -o DPkg::Options::=\"--force-confold\" -o DPkg::Options::=\"--force-confdef\"" + fi + + # Try to install packages + if eval "$apt_cmd" 2>&1 | tee /tmp/patchmon_apt_install.log; then + success "Packages installed successfully" + return 0 + else + warning "Package installation encountered issues, checking if required tools are available..." + + # Verify critical dependencies are actually available + local all_ok=true + for pkg in "${packages[@]}"; do + if ! command_exists "$pkg"; then + if [[ "$FORCE_INSTALL" == "true" ]]; then + error "Critical dependency '$pkg' is not available even with --force. Please install manually." + else + error "Critical dependency '$pkg' is not available. Try again with --force flag or install manually: apt-get install $pkg" + fi + all_ok=false + fi + done + + if $all_ok; then + success "All required tools are available despite installation warnings" + return 0 + else + return 1 + fi + fi +} + # Detect package manager and install jq and curl if command -v apt-get >/dev/null 2>&1; then # Debian/Ubuntu info "Detected apt-get (Debian/Ubuntu)" echo "" + + # Check for broken packages + if dpkg -l | grep -q "^iH\|^iF" 2>/dev/null; then + if [[ "$FORCE_INSTALL" == "true" ]]; then + warning "Detected broken packages on system - force mode will work around them" + else + warning "⚠️ Broken packages detected on system" + warning "If installation fails, retry with: curl -s {URL}/api/v1/hosts/install --force -H ..." + fi + fi + info "Updating package lists..." - apt-get update + apt-get update || true echo "" info "Installing jq, curl, and bc..." - apt-get install jq curl bc -y + install_apt_packages jq curl bc elif command -v yum >/dev/null 2>&1; then # CentOS/RHEL 7 info "Detected yum (CentOS/RHEL 7)" diff --git a/backend/src/routes/hostRoutes.js b/backend/src/routes/hostRoutes.js index bd0ba0c..5a076a5 100644 --- a/backend/src/routes/hostRoutes.js +++ b/backend/src/routes/hostRoutes.js @@ -1127,12 +1127,16 @@ router.get("/install", async (req, res) => { } } catch (_) {} - // Inject the API credentials, server URL, and curl flags into the script + // Check for --force parameter + const forceInstall = req.query.force === "true" || req.query.force === "1"; + + // Inject the API credentials, server URL, curl flags, and force flag into the script const envVars = `#!/bin/bash export PATCHMON_URL="${serverUrl}" export API_ID="${host.api_id}" export API_KEY="${host.api_key}" export CURL_FLAGS="${curlFlags}" +export FORCE_INSTALL="${forceInstall ? "true" : "false"}" `; From 2659a930d6df035c12b0ee847d7f736b6abb4b3d Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Sat, 4 Oct 2025 13:37:05 +0100 Subject: [PATCH 47/51] Add force flag to bypass broken packages upon installation --- agents/proxmox_auto_enroll.sh | 10 +++++- backend/src/routes/autoEnrollmentRoutes.js | 6 +++- frontend/src/pages/HostDetail.jsx | 32 +++++++++++++++++-- frontend/src/pages/settings/Integrations.jsx | 33 ++++++++++++++++++-- 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index ebfb20f..f999b90 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -33,6 +33,7 @@ HOST_PREFIX="${HOST_PREFIX:-}" SKIP_STOPPED="${SKIP_STOPPED:-true}" PARALLEL_INSTALL="${PARALLEL_INSTALL:-false}" MAX_PARALLEL="${MAX_PARALLEL:-5}" +FORCE_INSTALL="${FORCE_INSTALL:-false}" # ===== COLOR OUTPUT ===== RED='\033[0;31m' @@ -243,6 +244,13 @@ while IFS= read -r line; do # Install PatchMon agent in container info " Installing PatchMon agent..." + # Build install URL with force flag if enabled + install_url="$PATCHMON_URL/api/v1/hosts/install" + if [[ "$FORCE_INSTALL" == "true" ]]; then + install_url="$install_url?force=true" + info " Using force mode - will bypass broken packages" + fi + # Reset exit code for this container install_exit_code=0 @@ -253,7 +261,7 @@ while IFS= read -r line; do -H \"X-API-ID: $api_id\" \ -H \"X-API-KEY: $api_key\" \ -o patchmon-install.sh \ - '$PATCHMON_URL/api/v1/hosts/install' && \ + '$install_url' && \ bash patchmon-install.sh && \ rm -f patchmon-install.sh " 2>&1 { } } catch (_) {} - // Inject the token credentials, server URL, and curl flags into the script + // Check for --force parameter + const force_install = req.query.force === "true" || req.query.force === "1"; + + // Inject the token credentials, server URL, curl flags, and force flag into the script const env_vars = `#!/bin/bash # PatchMon Auto-Enrollment Configuration (Auto-generated) export PATCHMON_URL="${server_url}" export AUTO_ENROLLMENT_KEY="${token.token_key}" export AUTO_ENROLLMENT_SECRET="${token_secret}" export CURL_FLAGS="${curl_flags}" +export FORCE_INSTALL="${force_install ? "true" : "false"}" `; diff --git a/frontend/src/pages/HostDetail.jsx b/frontend/src/pages/HostDetail.jsx index 8d185ac..e2027b9 100644 --- a/frontend/src/pages/HostDetail.jsx +++ b/frontend/src/pages/HostDetail.jsx @@ -45,6 +45,7 @@ const HostDetail = () => { const [showDeleteModal, setShowDeleteModal] = useState(false); const [showAllUpdates, setShowAllUpdates] = useState(false); const [activeTab, setActiveTab] = useState("host"); + const [forceInstall, setForceInstall] = useState(false); const { data: host, @@ -1083,6 +1084,7 @@ const HostDetail = () => { const CredentialsModal = ({ host, isOpen, onClose }) => { const [showApiKey, setShowApiKey] = useState(false); const [activeTab, setActiveTab] = useState("quick-install"); + const [forceInstall, setForceInstall] = useState(false); const apiIdInputId = useId(); const apiKeyInputId = useId(); @@ -1104,6 +1106,12 @@ const CredentialsModal = ({ host, isOpen, onClose }) => { return settings?.ignore_ssl_self_signed ? "-sk" : "-s"; }; + // Helper function to build installation URL with optional force flag + const getInstallUrl = () => { + const baseUrl = `${serverUrl}/api/v1/hosts/install`; + return forceInstall ? `${baseUrl}?force=true` : baseUrl; + }; + const copyToClipboard = async (text) => { try { // Try modern clipboard API first @@ -1197,10 +1205,30 @@ const CredentialsModal = ({ host, isOpen, onClose }) => { Copy and run this command on the target host to securely install and configure the PatchMon agent:

+ + {/* Force Install Toggle */} +
+ +

+ Enable this if the target host has broken packages + (CloudPanel, WHM, etc.) that block apt-get operations +

+
+
@@ -1208,7 +1236,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => { type="button" onClick={() => copyToClipboard( - `curl ${getCurlFlags()} ${serverUrl}/api/v1/hosts/install -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | bash`, + `curl ${getCurlFlags()} ${getInstallUrl()} -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | bash`, ) } className="btn-primary flex items-center gap-1" diff --git a/frontend/src/pages/settings/Integrations.jsx b/frontend/src/pages/settings/Integrations.jsx index 0887350..8732c12 100644 --- a/frontend/src/pages/settings/Integrations.jsx +++ b/frontend/src/pages/settings/Integrations.jsx @@ -22,6 +22,7 @@ const Integrations = () => { const [new_token, setNewToken] = useState(null); const [show_secret, setShowSecret] = useState(false); const [server_url, setServerUrl] = useState(""); + const [force_proxmox_install, setForceProxmoxInstall] = useState(false); // Form state const [form_data, setFormData] = useState({ @@ -34,6 +35,12 @@ const Integrations = () => { const [copy_success, setCopySuccess] = useState({}); + // Helper function to build Proxmox enrollment URL with optional force flag + const getProxmoxUrl = () => { + const baseUrl = `${server_url}/api/v1/auto-enrollment/proxmox-lxc?token_key=${new_token.token_key}&token_secret=${new_token.token_secret}`; + return force_proxmox_install ? `${baseUrl}&force=true` : baseUrl; + }; + const handleTabChange = (tabName) => { setActiveTab(tabName); }; @@ -664,10 +671,32 @@ const Integrations = () => { Run this command on your Proxmox host to download and execute the enrollment script:

+ + {/* Force Install Toggle */} +
+ +

+ Enable this if your LXC containers have broken packages + (CloudPanel, WHM, etc.) that block apt-get operations +

+
+
@@ -675,7 +704,7 @@ const Integrations = () => { type="button" onClick={() => copy_to_clipboard( - `curl -s "${server_url}/api/v1/auto-enrollment/proxmox-lxc?token_key=${new_token.token_key}&token_secret=${new_token.token_secret}" | bash`, + `curl -s "${getProxmoxUrl()}" | bash`, "curl-command", ) } From 864719b4b3741d5052312766718eed721c8421fe Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Sat, 4 Oct 2025 20:27:41 +0100 Subject: [PATCH 48/51] feat: implement main branch vs release commit comparison - Add commit difference tracking between main branch and release tag - Show how many commits main branch is ahead of current release - Update UI to display branch status with clear messaging - Fix linting issues with useCallback and unused parameters - Simplify version display with My Version | Latest Release layout --- backend/src/routes/versionRoutes.js | 459 +++++++---- .../components/settings/VersionUpdateTab.jsx | 730 ++++++------------ 2 files changed, 557 insertions(+), 632 deletions(-) diff --git a/backend/src/routes/versionRoutes.js b/backend/src/routes/versionRoutes.js index 1ce0f20..57075a2 100644 --- a/backend/src/routes/versionRoutes.js +++ b/backend/src/routes/versionRoutes.js @@ -2,36 +2,259 @@ const express = require("express"); const { authenticateToken } = require("../middleware/auth"); const { requireManageSettings } = require("../middleware/permissions"); const { PrismaClient } = require("@prisma/client"); -const { exec } = require("node:child_process"); -const { promisify } = require("node:util"); const prisma = new PrismaClient(); -const execAsync = promisify(exec); + +// Default GitHub repository URL +const DEFAULT_GITHUB_REPO = "https://github.com/patchMon/patchmon"; const router = express.Router(); +// Helper function to get current version from package.json +function getCurrentVersion() { + try { + const packageJson = require("../../package.json"); + return packageJson?.version || "1.2.7"; + } catch (packageError) { + console.warn( + "Could not read version from package.json, using fallback:", + packageError.message, + ); + return "1.2.7"; + } +} + +// Helper function to parse GitHub repository URL +function parseGitHubRepo(repoUrl) { + let owner, repo; + + if (repoUrl.includes("git@github.com:")) { + const match = repoUrl.match(/git@github\.com:([^/]+)\/([^/]+)\.git/); + if (match) { + [, owner, repo] = match; + } + } else if (repoUrl.includes("github.com/")) { + const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/); + if (match) { + [, owner, repo] = match; + } + } + + return { owner, repo }; +} + +// Helper function to get latest release from GitHub API +async function getLatestRelease(owner, repo) { + try { + const currentVersion = getCurrentVersion(); + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`; + + const response = await fetch(apiUrl, { + method: "GET", + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": `PatchMon-Server/${currentVersion}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + if ( + errorText.includes("rate limit") || + errorText.includes("API rate limit") + ) { + throw new Error("GitHub API rate limit exceeded"); + } + throw new Error( + `GitHub API error: ${response.status} ${response.statusText}`, + ); + } + + const releaseData = await response.json(); + return { + tagName: releaseData.tag_name, + version: releaseData.tag_name.replace("v", ""), + publishedAt: releaseData.published_at, + htmlUrl: releaseData.html_url, + }; + } catch (error) { + console.error("Error fetching latest release:", error.message); + throw error; // Re-throw to be caught by the calling function + } +} + +// Helper function to get latest commit from main branch +async function getLatestCommit(owner, repo) { + try { + const currentVersion = getCurrentVersion(); + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/commits/main`; + + const response = await fetch(apiUrl, { + method: "GET", + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": `PatchMon-Server/${currentVersion}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + if ( + errorText.includes("rate limit") || + errorText.includes("API rate limit") + ) { + throw new Error("GitHub API rate limit exceeded"); + } + throw new Error( + `GitHub API error: ${response.status} ${response.statusText}`, + ); + } + + const commitData = await response.json(); + return { + sha: commitData.sha, + message: commitData.commit.message, + author: commitData.commit.author.name, + date: commitData.commit.author.date, + htmlUrl: commitData.html_url, + }; + } catch (error) { + console.error("Error fetching latest commit:", error.message); + throw error; // Re-throw to be caught by the calling function + } +} + +// Helper function to get commit count difference +async function getCommitDifference(owner, repo, currentVersion) { + try { + const currentVersionTag = `v${currentVersion}`; + // Compare main branch with the released version tag + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/compare/${currentVersionTag}...main`; + + const response = await fetch(apiUrl, { + method: "GET", + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": `PatchMon-Server/${getCurrentVersion()}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + if ( + errorText.includes("rate limit") || + errorText.includes("API rate limit") + ) { + throw new Error("GitHub API rate limit exceeded"); + } + throw new Error( + `GitHub API error: ${response.status} ${response.statusText}`, + ); + } + + const compareData = await response.json(); + return { + commitsBehind: compareData.behind_by || 0, // How many commits main is behind release + commitsAhead: compareData.ahead_by || 0, // How many commits main is ahead of release + totalCommits: compareData.total_commits || 0, + branchInfo: "main branch vs release", + }; + } catch (error) { + console.error("Error fetching commit difference:", error.message); + throw error; + } +} + +// Helper function to compare version strings (semantic versioning) +function compareVersions(version1, version2) { + const v1parts = version1.split(".").map(Number); + const v2parts = version2.split(".").map(Number); + + const maxLength = Math.max(v1parts.length, v2parts.length); + + for (let i = 0; i < maxLength; i++) { + const v1part = v1parts[i] || 0; + const v2part = v2parts[i] || 0; + + if (v1part > v2part) return 1; + if (v1part < v2part) return -1; + } + + return 0; +} + // Get current version info router.get("/current", authenticateToken, async (_req, res) => { try { - // Read version from package.json dynamically - let currentVersion = "1.2.7"; // fallback + const currentVersion = getCurrentVersion(); - try { - const packageJson = require("../../package.json"); - if (packageJson?.version) { - currentVersion = packageJson.version; + // Get GitHub repository info from settings or use default + const settings = await prisma.settings.findFirst(); + const githubRepoUrl = settings?.githubRepoUrl || DEFAULT_GITHUB_REPO; + const { owner, repo } = parseGitHubRepo(githubRepoUrl); + + let latestRelease = null; + let latestCommit = null; + let commitDifference = null; + + // Fetch GitHub data if we have valid owner/repo + if (owner && repo) { + try { + // Fetch latest release, latest commit, and commit difference in parallel + const [releaseData, commitData, differenceData] = await Promise.all([ + getLatestRelease(owner, repo), + getLatestCommit(owner, repo), + getCommitDifference(owner, repo, currentVersion), + ]); + + latestRelease = releaseData; + latestCommit = commitData; + commitDifference = differenceData; + } catch (githubError) { + console.warn("Failed to fetch GitHub data:", githubError.message); + + // Provide fallback data when GitHub API is rate-limited + if ( + githubError.message.includes("rate limit") || + githubError.message.includes("API rate limit") + ) { + console.log("GitHub API rate limited, providing fallback data"); + latestRelease = { + tagName: "v1.2.7", + version: "1.2.7", + publishedAt: "2025-10-02T17:12:53Z", + htmlUrl: "https://github.com/PatchMon/PatchMon/releases/tag/v1.2.7", + }; + latestCommit = { + sha: "cc89df161b8ea5d48ff95b0eb405fe69042052cd", + message: "Update README.md\n\nAdded Documentation Links", + author: "9 Technology Group LTD", + date: "2025-10-04T18:38:09Z", + htmlUrl: + "https://github.com/PatchMon/PatchMon/commit/cc89df161b8ea5d48ff95b0eb405fe69042052cd", + }; + commitDifference = { + commitsBehind: 0, + commitsAhead: 3, // Main branch is ahead of release + totalCommits: 3, + branchInfo: "main branch vs release", + }; + } } - } catch (packageError) { - console.warn( - "Could not read version from package.json, using fallback:", - packageError.message, - ); } res.json({ version: currentVersion, buildDate: new Date().toISOString(), environment: process.env.NODE_ENV || "development", + github: { + repository: githubRepoUrl, + owner: owner, + repo: repo, + latestRelease: latestRelease, + latestCommit: latestCommit, + commitDifference: commitDifference, + }, }); } catch (error) { console.error("Error getting current version:", error); @@ -44,119 +267,11 @@ router.post( "/test-ssh-key", authenticateToken, requireManageSettings, - async (req, res) => { - try { - const { sshKeyPath, githubRepoUrl } = req.body; - - if (!sshKeyPath || !githubRepoUrl) { - return res.status(400).json({ - error: "SSH key path and GitHub repo URL are required", - }); - } - - // Parse repository info - let owner, repo; - if (githubRepoUrl.includes("git@github.com:")) { - const match = githubRepoUrl.match( - /git@github\.com:([^/]+)\/([^/]+)\.git/, - ); - if (match) { - [, owner, repo] = match; - } - } else if (githubRepoUrl.includes("github.com/")) { - const match = githubRepoUrl.match(/github\.com\/([^/]+)\/([^/]+)/); - if (match) { - [, owner, repo] = match; - } - } - - if (!owner || !repo) { - return res.status(400).json({ - error: "Invalid GitHub repository URL format", - }); - } - - // Check if SSH key file exists and is readable - try { - require("node:fs").accessSync(sshKeyPath); - } catch { - return res.status(400).json({ - error: "SSH key file not found or not accessible", - details: `Cannot access: ${sshKeyPath}`, - suggestion: - "Check the file path and ensure the application has read permissions", - }); - } - - // Test SSH connection to GitHub - const sshRepoUrl = `git@github.com:${owner}/${repo}.git`; - const env = { - ...process.env, - GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -o ConnectTimeout=10`, - }; - - try { - // Test with a simple git command - const { stdout } = await execAsync( - `git ls-remote --heads ${sshRepoUrl} | head -n 1`, - { - timeout: 15000, - env: env, - }, - ); - - if (stdout.trim()) { - return res.json({ - success: true, - message: "SSH key is working correctly", - details: { - sshKeyPath, - repository: `${owner}/${repo}`, - testResult: "Successfully connected to GitHub", - }, - }); - } else { - return res.status(400).json({ - error: "SSH connection succeeded but no data returned", - suggestion: "Check repository access permissions", - }); - } - } catch (sshError) { - console.error("SSH test error:", sshError.message); - - if (sshError.message.includes("Permission denied")) { - return res.status(403).json({ - error: "SSH key permission denied", - details: "The SSH key exists but GitHub rejected the connection", - suggestion: - "Verify the SSH key is added to the repository as a deploy key with read access", - }); - } else if (sshError.message.includes("Host key verification failed")) { - return res.status(403).json({ - error: "Host key verification failed", - suggestion: - "This is normal for first-time connections. The key will be added to known_hosts automatically.", - }); - } else if (sshError.message.includes("Connection timed out")) { - return res.status(408).json({ - error: "Connection timed out", - suggestion: "Check your internet connection and GitHub status", - }); - } else { - return res.status(500).json({ - error: "SSH connection failed", - details: sshError.message, - suggestion: "Check the SSH key format and repository URL", - }); - } - } - } catch (error) { - console.error("SSH key test error:", error); - res.status(500).json({ - error: "Failed to test SSH key", - details: error.message, - }); - } + async (_req, res) => { + res.status(410).json({ + error: + "SSH key testing has been removed. Using default public repository.", + }); }, ); @@ -174,24 +289,90 @@ router.get( return res.status(400).json({ error: "Settings not found" }); } - const currentVersion = "1.2.7"; - const latestVersion = settings.latest_version || currentVersion; - const isUpdateAvailable = settings.update_available || false; - const lastUpdateCheck = settings.last_update_check || null; + const currentVersion = getCurrentVersion(); + const githubRepoUrl = settings.githubRepoUrl || DEFAULT_GITHUB_REPO; + const { owner, repo } = parseGitHubRepo(githubRepoUrl); + + let latestRelease = null; + let latestCommit = null; + let commitDifference = null; + + // Fetch fresh GitHub data if we have valid owner/repo + if (owner && repo) { + try { + const [releaseData, commitData, differenceData] = await Promise.all([ + getLatestRelease(owner, repo), + getLatestCommit(owner, repo), + getCommitDifference(owner, repo, currentVersion), + ]); + + latestRelease = releaseData; + latestCommit = commitData; + commitDifference = differenceData; + } catch (githubError) { + console.warn( + "Failed to fetch fresh GitHub data:", + githubError.message, + ); + + // Provide fallback data when GitHub API is rate-limited + if ( + githubError.message.includes("rate limit") || + githubError.message.includes("API rate limit") + ) { + console.log("GitHub API rate limited, providing fallback data"); + latestRelease = { + tagName: "v1.2.7", + version: "1.2.7", + publishedAt: "2025-10-02T17:12:53Z", + htmlUrl: + "https://github.com/PatchMon/PatchMon/releases/tag/v1.2.7", + }; + latestCommit = { + sha: "cc89df161b8ea5d48ff95b0eb405fe69042052cd", + message: "Update README.md\n\nAdded Documentation Links", + author: "9 Technology Group LTD", + date: "2025-10-04T18:38:09Z", + htmlUrl: + "https://github.com/PatchMon/PatchMon/commit/cc89df161b8ea5d48ff95b0eb405fe69042052cd", + }; + commitDifference = { + commitsBehind: 0, + commitsAhead: 3, // Main branch is ahead of release + totalCommits: 3, + branchInfo: "main branch vs release", + }; + } else { + // Fall back to cached data for other errors + latestRelease = settings.latest_version + ? { + version: settings.latest_version, + tagName: `v${settings.latest_version}`, + } + : null; + } + } + } + + const latestVersion = + latestRelease?.version || settings.latest_version || currentVersion; + const isUpdateAvailable = latestRelease + ? compareVersions(latestVersion, currentVersion) > 0 + : settings.update_available || false; res.json({ currentVersion, latestVersion, isUpdateAvailable, - lastUpdateCheck, + lastUpdateCheck: settings.last_update_check || null, repositoryType: settings.repository_type || "public", - latestRelease: { - tagName: latestVersion ? `v${latestVersion}` : null, - version: latestVersion, - repository: settings.github_repo_url - ? settings.github_repo_url.split("/").slice(-2).join("/") - : null, - accessMethod: settings.repository_type === "private" ? "ssh" : "api", + github: { + repository: githubRepoUrl, + owner: owner, + repo: repo, + latestRelease: latestRelease, + latestCommit: latestCommit, + commitDifference: commitDifference, }, }); } catch (error) { diff --git a/frontend/src/components/settings/VersionUpdateTab.jsx b/frontend/src/components/settings/VersionUpdateTab.jsx index 4462445..ebd4796 100644 --- a/frontend/src/components/settings/VersionUpdateTab.jsx +++ b/frontend/src/components/settings/VersionUpdateTab.jsx @@ -1,30 +1,16 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, CheckCircle, Clock, Code, Download, - Save, + ExternalLink, + GitCommit, } from "lucide-react"; -import { useEffect, useId, useState } from "react"; -import { settingsAPI, versionAPI } from "../../utils/api"; +import { useCallback, useEffect, useState } from "react"; +import { versionAPI } from "../../utils/api"; const VersionUpdateTab = () => { - const repoPublicId = useId(); - const repoPrivateId = useId(); - const useCustomSshKeyId = useId(); - const githubRepoUrlId = useId(); - const sshKeyPathId = useId(); - const [formData, setFormData] = useState({ - githubRepoUrl: "git@github.com:9technologygroup/patchmon.net.git", - repositoryType: "public", - sshKeyPath: "", - useCustomSshKey: false, - }); - const [errors, setErrors] = useState({}); - const [isDirty, setIsDirty] = useState(false); - // Version checking state const [versionInfo, setVersionInfo] = useState({ currentVersion: null, @@ -32,89 +18,11 @@ const VersionUpdateTab = () => { isUpdateAvailable: false, checking: false, error: null, + github: null, }); - const [sshTestResult, setSshTestResult] = useState({ - testing: false, - success: null, - message: null, - error: null, - }); - - const queryClient = useQueryClient(); - - // Fetch current settings - const { - data: settings, - isLoading, - error, - } = useQuery({ - queryKey: ["settings"], - queryFn: () => settingsAPI.get().then((res) => res.data), - }); - - // Update form data when settings are loaded - useEffect(() => { - if (settings) { - const newFormData = { - githubRepoUrl: - settings.github_repo_url || - "git@github.com:9technologygroup/patchmon.net.git", - repositoryType: settings.repository_type || "public", - sshKeyPath: settings.ssh_key_path || "", - useCustomSshKey: !!settings.ssh_key_path, - }; - setFormData(newFormData); - setIsDirty(false); - } - }, [settings]); - - // Update settings mutation - const updateSettingsMutation = useMutation({ - mutationFn: (data) => { - return settingsAPI.update(data).then((res) => res.data); - }, - onSuccess: () => { - queryClient.invalidateQueries(["settings"]); - setIsDirty(false); - setErrors({}); - }, - onError: (error) => { - if (error.response?.data?.errors) { - setErrors( - error.response.data.errors.reduce((acc, err) => { - acc[err.path] = err.msg; - return acc; - }, {}), - ); - } else { - setErrors({ - general: error.response?.data?.error || "Failed to update settings", - }); - } - }, - }); - - // Load current version on component mount - useEffect(() => { - const loadCurrentVersion = async () => { - try { - const response = await versionAPI.getCurrent(); - const data = response.data; - setVersionInfo((prev) => ({ - ...prev, - currentVersion: data.version, - })); - } catch (error) { - console.error("Error loading current version:", error); - } - }; - - loadCurrentVersion(); - }, []); - // Version checking functions - const checkForUpdates = async () => { + const checkForUpdates = useCallback(async () => { setVersionInfo((prev) => ({ ...prev, checking: true, error: null })); try { @@ -126,6 +34,7 @@ const VersionUpdateTab = () => { latestVersion: data.latestVersion, isUpdateAvailable: data.isUpdateAvailable, last_update_check: data.last_update_check, + github: data.github, checking: false, error: null, }); @@ -137,434 +46,269 @@ const VersionUpdateTab = () => { error: error.response?.data?.error || "Failed to check for updates", })); } - }; + }, []); - const testSshKey = async () => { - if (!formData.sshKeyPath || !formData.githubRepoUrl) { - setSshTestResult({ - testing: false, - success: false, - message: null, - error: "Please enter both SSH key path and GitHub repository URL", - }); - return; - } + // Load current version on component mount + useEffect(() => { + const loadCurrentVersion = async () => { + try { + const response = await versionAPI.getCurrent(); + const data = response.data; + setVersionInfo((prev) => ({ + ...prev, + currentVersion: data.version, + github: data.github, + })); + } catch (error) { + console.error("Error loading current version:", error); + } + }; - setSshTestResult({ - testing: true, - success: null, - message: null, - error: null, - }); + // Load current version and immediately check for updates + const loadAndCheckUpdates = async () => { + await loadCurrentVersion(); + // Automatically trigger update check when component loads + await checkForUpdates(); + }; - try { - const response = await versionAPI.testSshKey({ - sshKeyPath: formData.sshKeyPath, - githubRepoUrl: formData.githubRepoUrl, - }); - - setSshTestResult({ - testing: false, - success: true, - message: response.data.message, - error: null, - }); - } catch (error) { - console.error("SSH key test error:", error); - setSshTestResult({ - testing: false, - success: false, - message: null, - error: error.response?.data?.error || "Failed to test SSH key", - }); - } - }; - - const handleInputChange = (field, value) => { - setFormData((prev) => ({ - ...prev, - [field]: value, - })); - setIsDirty(true); - if (errors[field]) { - setErrors((prev) => ({ ...prev, [field]: null })); - } - }; - - const handleSave = () => { - // Only include sshKeyPath if the toggle is enabled - const dataToSubmit = { ...formData }; - if (!dataToSubmit.useCustomSshKey) { - dataToSubmit.sshKeyPath = ""; - } - // Remove the frontend-only field - delete dataToSubmit.useCustomSshKey; - - updateSettingsMutation.mutate(dataToSubmit); - }; - - if (isLoading) { - return ( -
-
-
- ); - } - - if (error) { - return ( -
-
- -
-

- Error loading settings -

-

- {error.response?.data?.error || "Failed to load settings"} -

-
-
-
- ); - } + loadAndCheckUpdates(); + }, [checkForUpdates]); // Include checkForUpdates dependency return (
- {errors.general && ( -
-
- -
-

- {errors.general} -

-
-
-
- )} -

- Server Version Management + Server Version Information

- Version Check Configuration + Version Information

- Configure automatic version checking against your GitHub repository to - notify users of available updates. + Current server version and latest updates from GitHub repository. + {versionInfo.checking && ( + + 🔄 Checking for updates... + + )}

-
-
- - Repository Type - -
-
- - handleInputChange("repositoryType", e.target.value) - } - className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300" - /> - -
-
- - handleInputChange("repositoryType", e.target.value) - } - className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300" - /> - -
-
-

- Choose whether your repository is public or private to determine - the appropriate access method. -

-
- -
- - - handleInputChange("githubRepoUrl", e.target.value) - } - className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm" - placeholder="git@github.com:username/repository.git" - /> -

- SSH or HTTPS URL to your GitHub repository -

-
- - {formData.repositoryType === "private" && ( -
-
- { - const checked = e.target.checked; - handleInputChange("useCustomSshKey", checked); - if (!checked) { - handleInputChange("sshKeyPath", ""); - } - }} - className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded" - /> - -
- - {formData.useCustomSshKey && ( -
- - - handleInputChange("sshKeyPath", e.target.value) - } - className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm" - placeholder="/root/.ssh/id_ed25519" - /> -

- Path to your SSH deploy key. If not set, will auto-detect - from common locations. -

- -
- - - {sshTestResult.success && ( -
-
- -

- {sshTestResult.message} -

-
-
- )} - - {sshTestResult.error && ( -
-
- -

- {sshTestResult.error} -

-
-
- )} -
-
- )} - - {!formData.useCustomSshKey && ( -

- Using auto-detection for SSH key location -

- )} -
- )} - -
-
-
- - - Current Version - -
- - {versionInfo.currentVersion} +
+ {/* My Version */} +
+
+ + + My Version
+ + {versionInfo.currentVersion} + +
+ {/* Latest Release */} + {versionInfo.github?.latestRelease && (
- Latest Version + Latest Release
- - {versionInfo.checking ? ( - - Checking... - - ) : versionInfo.latestVersion ? ( - - {versionInfo.latestVersion} - {versionInfo.isUpdateAvailable && " (Update Available!)"} - - ) : ( - - Not checked - - )} - -
-
- - {/* Last Checked Time */} - {versionInfo.last_update_check && ( -
-
- - - Last Checked +
+ + {versionInfo.github.latestRelease.tagName} -
- - {new Date(versionInfo.last_update_check).toLocaleString()} - -

- Updates are checked automatically every 24 hours -

-
- )} - -
-
- -
- - {/* Save Button for Version Settings */} - -
- - {versionInfo.error && ( -
-
- -
-

- Version Check Failed -

-

- {versionInfo.error} -

- {versionInfo.error.includes("private") && ( -

- For private repositories, you may need to configure GitHub - authentication or make the repository public. -

- )} -
-
-
- )} - - {/* Success Message for Version Settings */} - {updateSettingsMutation.isSuccess && ( -
-
- -
-

- Settings saved successfully! -

+
+ Published:{" "} + {new Date( + versionInfo.github.latestRelease.publishedAt, + ).toLocaleDateString()}
)}
+ + {/* GitHub Repository Information */} + {versionInfo.github && ( +
+
+ + + GitHub Repository Information + +
+ +
+ {/* Repository URL */} +
+ + Repository + +
+ + {versionInfo.github.owner}/{versionInfo.github.repo} + + {versionInfo.github.repository && ( + + + + )} +
+
+ + {/* Latest Release Info */} + {versionInfo.github.latestRelease && ( +
+ + Release Link + +
+ {versionInfo.github.latestRelease.htmlUrl && ( + + View Release{" "} + + + )} +
+
+ )} + + {/* Branch Status */} + {versionInfo.github.commitDifference && ( +
+ + Branch Status + +
+ {versionInfo.github.commitDifference.commitsAhead > 0 ? ( + + 🚀 Main branch is{" "} + {versionInfo.github.commitDifference.commitsAhead}{" "} + commits ahead of release + + ) : versionInfo.github.commitDifference.commitsBehind > + 0 ? ( + + 📊 Main branch is{" "} + {versionInfo.github.commitDifference.commitsBehind}{" "} + commits behind release + + ) : ( + + ✅ Main branch is in sync with release + + )} +
+
+ )} +
+ + {/* Latest Commit Information */} + {versionInfo.github.latestCommit && ( +
+ )} +
+ )} + + {/* Last Checked Time */} + {versionInfo.last_update_check && ( +
+
+ + + Last Checked + +
+ + {new Date(versionInfo.last_update_check).toLocaleString()} + +

+ Updates are checked automatically every 24 hours +

+
+ )} + +
+ +
+ + {versionInfo.error && ( +
+
+ +
+

+ Version Check Failed +

+

+ {versionInfo.error} +

+
+
+
+ )}
); From a43fc9d3806d8e30c071ed116e615a4a75037083 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Sat, 4 Oct 2025 20:29:46 +0100 Subject: [PATCH 49/51] fix: remove outdated GitHub repository warning - Update updateScheduler to use default GitHub repository - Remove 'No GitHub repository configured' warning message - Use same default fallback logic as version routes --- backend/src/services/updateScheduler.js | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/backend/src/services/updateScheduler.js b/backend/src/services/updateScheduler.js index 074cf8e..c5c3454 100644 --- a/backend/src/services/updateScheduler.js +++ b/backend/src/services/updateScheduler.js @@ -60,13 +60,8 @@ class UpdateScheduler { // Get settings const settings = await prisma.settings.findFirst(); - if (!settings || !settings.githubRepoUrl) { - console.log("⚠️ No GitHub repository configured, skipping update check"); - return; - } - - // Extract owner and repo from GitHub URL - const repoUrl = settings.githubRepoUrl; + const DEFAULT_GITHUB_REPO = "https://github.com/patchMon/patchmon"; + const repoUrl = settings?.githubRepoUrl || DEFAULT_GITHUB_REPO; let owner, repo; if (repoUrl.includes("git@github.com:")) { From 3ea8cc74b640a3f7527630e1c8333b8952d314c9 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Sat, 4 Oct 2025 20:30:58 +0100 Subject: [PATCH 50/51] fix: resolve updateScheduler database and API issues - Fix database field names: lastUpdateCheck -> last_update_check - Fix database field names: updateAvailable -> update_available - Fix database field names: latestVersion -> latest_version - Add graceful GitHub API rate limit handling - Return null instead of throwing error on rate limit - Prevent database update errors on API failures --- backend/src/services/updateScheduler.js | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/backend/src/services/updateScheduler.js b/backend/src/services/updateScheduler.js index c5c3454..34f1979 100644 --- a/backend/src/services/updateScheduler.js +++ b/backend/src/services/updateScheduler.js @@ -123,9 +123,9 @@ class UpdateScheduler { await prisma.settings.update({ where: { id: settings.id }, data: { - lastUpdateCheck: new Date(), - updateAvailable: isUpdateAvailable, - latestVersion: latestVersion, + last_update_check: new Date(), + update_available: isUpdateAvailable, + latest_version: latestVersion, }, }); @@ -142,8 +142,8 @@ class UpdateScheduler { await prisma.settings.update({ where: { id: settings.id }, data: { - lastUpdateCheck: new Date(), - updateAvailable: false, + last_update_check: new Date(), + update_available: false, }, }); } @@ -236,6 +236,16 @@ class UpdateScheduler { }); if (!response.ok) { + const errorText = await response.text(); + if ( + errorText.includes("rate limit") || + errorText.includes("API rate limit") + ) { + console.log( + "⚠️ GitHub API rate limit exceeded, skipping update check", + ); + return null; // Return null instead of throwing error + } throw new Error( `GitHub API error: ${response.status} ${response.statusText}`, ); From 6988ecab12d6d32984a76a765c06126abb8f4054 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Sun, 5 Oct 2025 10:55:34 +0100 Subject: [PATCH 51/51] Made github version checking better Added functionality of Logo branding Modified sidebar width --- .../migration.sql | 4 + backend/prisma/schema.prisma | 3 + backend/src/routes/settingsRoutes.js | 189 +++++++ backend/src/routes/versionRoutes.js | 60 +- backend/src/utils/session_manager.js | 2 +- frontend/index.html | 2 +- frontend/src/App.jsx | 15 +- frontend/src/components/DiscordIcon.jsx | 16 + frontend/src/components/GlobalSearch.jsx | 8 +- frontend/src/components/Layout.jsx | 105 ++-- frontend/src/components/Logo.jsx | 44 ++ frontend/src/components/LogoProvider.jsx | 37 ++ frontend/src/components/SettingsLayout.jsx | 6 + .../src/components/settings/BrandingTab.jsx | 531 ++++++++++++++++++ .../components/settings/VersionUpdateTab.jsx | 33 +- .../contexts/UpdateNotificationContext.jsx | 42 +- frontend/src/pages/HostDetail.jsx | 2 +- frontend/src/pages/Settings.jsx | 406 +++++++++++++ .../pages/settings/SettingsServerConfig.jsx | 14 +- 19 files changed, 1373 insertions(+), 146 deletions(-) create mode 100644 backend/prisma/migrations/20251004221657_add_logo_fields_to_settings/migration.sql create mode 100644 frontend/src/components/DiscordIcon.jsx create mode 100644 frontend/src/components/Logo.jsx create mode 100644 frontend/src/components/LogoProvider.jsx create mode 100644 frontend/src/components/settings/BrandingTab.jsx diff --git a/backend/prisma/migrations/20251004221657_add_logo_fields_to_settings/migration.sql b/backend/prisma/migrations/20251004221657_add_logo_fields_to_settings/migration.sql new file mode 100644 index 0000000..1c90303 --- /dev/null +++ b/backend/prisma/migrations/20251004221657_add_logo_fields_to_settings/migration.sql @@ -0,0 +1,4 @@ +-- AddLogoFieldsToSettings +ALTER TABLE "settings" ADD COLUMN "logo_dark" VARCHAR(255) DEFAULT '/assets/logo_dark.png'; +ALTER TABLE "settings" ADD COLUMN "logo_light" VARCHAR(255) DEFAULT '/assets/logo_light.png'; +ALTER TABLE "settings" ADD COLUMN "favicon" VARCHAR(255) DEFAULT '/assets/logo_square.svg'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index adca449..60f4732 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -164,6 +164,9 @@ model settings { signup_enabled Boolean @default(false) default_user_role String @default("user") ignore_ssl_self_signed Boolean @default(false) + logo_dark String? @default("/assets/logo_dark.png") + logo_light String? @default("/assets/logo_light.png") + favicon String? @default("/assets/logo_square.svg") } model update_history { diff --git a/backend/src/routes/settingsRoutes.js b/backend/src/routes/settingsRoutes.js index 0474e6d..965bcdb 100644 --- a/backend/src/routes/settingsRoutes.js +++ b/backend/src/routes/settingsRoutes.js @@ -215,6 +215,18 @@ router.put( } return true; }), + body("logoDark") + .optional() + .isLength({ min: 1 }) + .withMessage("Logo dark path must be a non-empty string"), + body("logoLight") + .optional() + .isLength({ min: 1 }) + .withMessage("Logo light path must be a non-empty string"), + body("favicon") + .optional() + .isLength({ min: 1 }) + .withMessage("Favicon path must be a non-empty string"), ], async (req, res) => { try { @@ -236,6 +248,9 @@ router.put( githubRepoUrl, repositoryType, sshKeyPath, + logoDark, + logoLight, + favicon, } = req.body; // Get current settings to check for update interval changes @@ -264,6 +279,9 @@ router.put( if (repositoryType !== undefined) updateData.repository_type = repositoryType; if (sshKeyPath !== undefined) updateData.ssh_key_path = sshKeyPath; + if (logoDark !== undefined) updateData.logo_dark = logoDark; + if (logoLight !== undefined) updateData.logo_light = logoLight; + if (favicon !== undefined) updateData.favicon = favicon; const updatedSettings = await updateSettings( currentSettings.id, @@ -351,4 +369,175 @@ router.get("/auto-update", async (_req, res) => { } }); +// Upload logo files +router.post( + "/logos/upload", + authenticateToken, + requireManageSettings, + async (req, res) => { + try { + const { logoType, fileContent, fileName } = req.body; + + if (!logoType || !fileContent) { + return res.status(400).json({ + error: "Logo type and file content are required", + }); + } + + if (!["dark", "light", "favicon"].includes(logoType)) { + return res.status(400).json({ + error: "Logo type must be 'dark', 'light', or 'favicon'", + }); + } + + // Validate file content (basic checks) + if (typeof fileContent !== "string") { + return res.status(400).json({ + error: "File content must be a base64 string", + }); + } + + const fs = require("node:fs").promises; + const path = require("node:path"); + const _crypto = require("node:crypto"); + + // Create assets directory if it doesn't exist + // In development: save to public/assets (served by Vite) + // In production: save to dist/assets (served by built app) + const isDevelopment = process.env.NODE_ENV !== "production"; + const assetsDir = isDevelopment + ? path.join(__dirname, "../../../frontend/public/assets") + : path.join(__dirname, "../../../frontend/dist/assets"); + await fs.mkdir(assetsDir, { recursive: true }); + + // Determine file extension and path + let fileExtension; + let fileName_final; + + if (logoType === "favicon") { + fileExtension = ".svg"; + fileName_final = fileName || "logo_square.svg"; + } else { + // Determine extension from file content or use default + if (fileContent.startsWith("data:image/png")) { + fileExtension = ".png"; + } else if (fileContent.startsWith("data:image/svg")) { + fileExtension = ".svg"; + } else if ( + fileContent.startsWith("data:image/jpeg") || + fileContent.startsWith("data:image/jpg") + ) { + fileExtension = ".jpg"; + } else { + fileExtension = ".png"; // Default to PNG + } + fileName_final = fileName || `logo_${logoType}${fileExtension}`; + } + + const filePath = path.join(assetsDir, fileName_final); + + // Handle base64 data URLs + let fileBuffer; + if (fileContent.startsWith("data:")) { + const base64Data = fileContent.split(",")[1]; + fileBuffer = Buffer.from(base64Data, "base64"); + } else { + // Assume it's already base64 + fileBuffer = Buffer.from(fileContent, "base64"); + } + + // Create backup of existing file + try { + const backupPath = `${filePath}.backup.${Date.now()}`; + await fs.copyFile(filePath, backupPath); + console.log(`Created backup: ${backupPath}`); + } catch (error) { + // Ignore if original doesn't exist + if (error.code !== "ENOENT") { + console.warn("Failed to create backup:", error.message); + } + } + + // Write new logo file + await fs.writeFile(filePath, fileBuffer); + + // Update settings with new logo path + const settings = await getSettings(); + const logoPath = `/assets/${fileName_final}`; + + const updateData = {}; + if (logoType === "dark") { + updateData.logo_dark = logoPath; + } else if (logoType === "light") { + updateData.logo_light = logoPath; + } else if (logoType === "favicon") { + updateData.favicon = logoPath; + } + + await updateSettings(settings.id, updateData); + + // Get file stats + const stats = await fs.stat(filePath); + + res.json({ + message: `${logoType} logo uploaded successfully`, + fileName: fileName_final, + path: logoPath, + size: stats.size, + sizeFormatted: `${(stats.size / 1024).toFixed(1)} KB`, + }); + } catch (error) { + console.error("Upload logo error:", error); + res.status(500).json({ error: "Failed to upload logo" }); + } + }, +); + +// Reset logo to default +router.post( + "/logos/reset", + authenticateToken, + requireManageSettings, + async (req, res) => { + try { + const { logoType } = req.body; + + if (!logoType) { + return res.status(400).json({ + error: "Logo type is required", + }); + } + + if (!["dark", "light", "favicon"].includes(logoType)) { + return res.status(400).json({ + error: "Logo type must be 'dark', 'light', or 'favicon'", + }); + } + + // Get current settings + const settings = await getSettings(); + + // Clear the custom logo path to revert to default + const updateData = {}; + if (logoType === "dark") { + updateData.logo_dark = null; + } else if (logoType === "light") { + updateData.logo_light = null; + } else if (logoType === "favicon") { + updateData.favicon = null; + } + + await updateSettings(settings.id, updateData); + + res.json({ + message: `${logoType} logo reset to default successfully`, + logoType, + }); + } catch (error) { + console.error("Reset logo error:", error); + res.status(500).json({ error: "Failed to reset logo" }); + } + }, +); + module.exports = router; diff --git a/backend/src/routes/versionRoutes.js b/backend/src/routes/versionRoutes.js index 57075a2..81b82c9 100644 --- a/backend/src/routes/versionRoutes.js +++ b/backend/src/routes/versionRoutes.js @@ -188,72 +188,24 @@ router.get("/current", authenticateToken, async (_req, res) => { try { const currentVersion = getCurrentVersion(); - // Get GitHub repository info from settings or use default + // Get settings with cached update info (no GitHub API calls) const settings = await prisma.settings.findFirst(); const githubRepoUrl = settings?.githubRepoUrl || DEFAULT_GITHUB_REPO; const { owner, repo } = parseGitHubRepo(githubRepoUrl); - let latestRelease = null; - let latestCommit = null; - let commitDifference = null; - - // Fetch GitHub data if we have valid owner/repo - if (owner && repo) { - try { - // Fetch latest release, latest commit, and commit difference in parallel - const [releaseData, commitData, differenceData] = await Promise.all([ - getLatestRelease(owner, repo), - getLatestCommit(owner, repo), - getCommitDifference(owner, repo, currentVersion), - ]); - - latestRelease = releaseData; - latestCommit = commitData; - commitDifference = differenceData; - } catch (githubError) { - console.warn("Failed to fetch GitHub data:", githubError.message); - - // Provide fallback data when GitHub API is rate-limited - if ( - githubError.message.includes("rate limit") || - githubError.message.includes("API rate limit") - ) { - console.log("GitHub API rate limited, providing fallback data"); - latestRelease = { - tagName: "v1.2.7", - version: "1.2.7", - publishedAt: "2025-10-02T17:12:53Z", - htmlUrl: "https://github.com/PatchMon/PatchMon/releases/tag/v1.2.7", - }; - latestCommit = { - sha: "cc89df161b8ea5d48ff95b0eb405fe69042052cd", - message: "Update README.md\n\nAdded Documentation Links", - author: "9 Technology Group LTD", - date: "2025-10-04T18:38:09Z", - htmlUrl: - "https://github.com/PatchMon/PatchMon/commit/cc89df161b8ea5d48ff95b0eb405fe69042052cd", - }; - commitDifference = { - commitsBehind: 0, - commitsAhead: 3, // Main branch is ahead of release - totalCommits: 3, - branchInfo: "main branch vs release", - }; - } - } - } - + // Return current version and cached update information + // The backend scheduler updates this data periodically res.json({ version: currentVersion, + latest_version: settings?.latest_version || null, + is_update_available: settings?.is_update_available || false, + last_update_check: settings?.last_update_check || null, buildDate: new Date().toISOString(), environment: process.env.NODE_ENV || "development", github: { repository: githubRepoUrl, owner: owner, repo: repo, - latestRelease: latestRelease, - latestCommit: latestCommit, - commitDifference: commitDifference, }, }); } catch (error) { diff --git a/backend/src/utils/session_manager.js b/backend/src/utils/session_manager.js index d70a7c0..83cbf74 100644 --- a/backend/src/utils/session_manager.js +++ b/backend/src/utils/session_manager.js @@ -1,5 +1,5 @@ const jwt = require("jsonwebtoken"); -const crypto = require("crypto"); +const crypto = require("node:crypto"); const { PrismaClient } = require("@prisma/client"); const prisma = new PrismaClient(); diff --git a/frontend/index.html b/frontend/index.html index b51290e..253c75c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + PatchMon - Linux Patch Monitoring Dashboard diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a9642ca..a8399e7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,7 @@ import { Route, Routes } from "react-router-dom"; import FirstTimeAdminSetup from "./components/FirstTimeAdminSetup"; import Layout from "./components/Layout"; +import LogoProvider from "./components/LogoProvider"; import ProtectedRoute from "./components/ProtectedRoute"; import SettingsLayout from "./components/SettingsLayout"; import { isAuthPhase } from "./constants/authPhases"; @@ -290,6 +291,16 @@ function AppRoutes() { } /> + + + + + + } + /> - + + + diff --git a/frontend/src/components/DiscordIcon.jsx b/frontend/src/components/DiscordIcon.jsx new file mode 100644 index 0000000..dd85546 --- /dev/null +++ b/frontend/src/components/DiscordIcon.jsx @@ -0,0 +1,16 @@ +const DiscordIcon = ({ className = "h-5 w-5" }) => { + return ( + + Discord + + + ); +}; + +export default DiscordIcon; diff --git a/frontend/src/components/GlobalSearch.jsx b/frontend/src/components/GlobalSearch.jsx index 27b515b..d1f5c91 100644 --- a/frontend/src/components/GlobalSearch.jsx +++ b/frontend/src/components/GlobalSearch.jsx @@ -250,7 +250,7 @@ const GlobalSearch = () => {
Hosts
- {results.hosts.map((host, idx) => { + {results.hosts.map((host, _idx) => { const display = getResultDisplay(host); const globalIdx = navigableResults.findIndex( (r) => r.id === host.id && r.type === "host", @@ -291,7 +291,7 @@ const GlobalSearch = () => {
Packages
- {results.packages.map((pkg, idx) => { + {results.packages.map((pkg, _idx) => { const display = getResultDisplay(pkg); const globalIdx = navigableResults.findIndex( (r) => r.id === pkg.id && r.type === "package", @@ -338,7 +338,7 @@ const GlobalSearch = () => {
Repositories
- {results.repositories.map((repo, idx) => { + {results.repositories.map((repo, _idx) => { const display = getResultDisplay(repo); const globalIdx = navigableResults.findIndex( (r) => r.id === repo.id && r.type === "repository", @@ -379,7 +379,7 @@ const GlobalSearch = () => {
Users
- {results.users.map((user, idx) => { + {results.users.map((user, _idx) => { const display = getResultDisplay(user); const globalIdx = navigableResults.findIndex( (r) => r.id === user.id && r.type === "user", diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 8541f2f..a2b3435 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { Activity, BarChart3, + BookOpen, ChevronLeft, ChevronRight, Clock, @@ -13,13 +14,12 @@ import { LogOut, Mail, Menu, - MessageCircle, Package, Plus, RefreshCw, + Route, Server, Settings, - Shield, Star, UserCircle, X, @@ -29,7 +29,9 @@ import { Link, useLocation, useNavigate } from "react-router-dom"; import { useAuth } from "../contexts/AuthContext"; import { useUpdateNotification } from "../contexts/UpdateNotificationContext"; import { dashboardAPI, versionAPI } from "../utils/api"; +import DiscordIcon from "./DiscordIcon"; import GlobalSearch from "./GlobalSearch"; +import Logo from "./Logo"; import UpgradeNotificationIcon from "./UpgradeNotificationIcon"; const Layout = ({ children }) => { @@ -293,7 +295,7 @@ const Layout = ({ children }) => { onClick={() => setSidebarOpen(false)} aria-label="Close sidebar" /> -
+
-
-
- -

- PatchMon -

-
+
+ + +