mirror of
				https://github.com/9technologygroup/patchmon.net.git
				synced 2025-11-04 05:53:27 +00:00 
			
		
		
		
	Compare commits
	
		
			73 Commits
		
	
	
		
			v1.2.7
			...
			ci/docker_
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					aab6fc244e | ||
| 
						 | 
					a2464fac5c | ||
| 
						 | 
					5dc3e8ba81 | ||
| 
						 | 
					63817b450f | ||
| 
						 | 
					1fa0502d7d | ||
| 
						 | 
					581dc5884c | ||
| 
						 | 
					dcaffe2805 | ||
| 
						 | 
					a3005bccb4 | ||
| 
						 | 
					499ef9d5d9 | ||
| 
						 | 
					6eb6ea3fd6 | ||
| 
						 | 
					a27c607d9e | ||
| 
						 | 
					d4e0abd407 | ||
| 
						 | 
					8d447cab0d | ||
| 
						 | 
					6988ecab12 | ||
| 
						 | 
					fd108c6a21 | ||
| 
						 | 
					3ea8cc74b6 | ||
| 
						 | 
					a43fc9d380 | ||
| 
						 | 
					864719b4b3 | ||
| 
						 | 
					cc89df161b | ||
| 
						 | 
					2659a930d6 | ||
| 
						 | 
					fa57b35270 | ||
| 
						 | 
					766d36ff80 | ||
| 
						 | 
					3a76d54707 | ||
| 
						 | 
					dd28e741d4 | ||
| 
						 | 
					35d3c28ae5 | ||
| 
						 | 
					3cf2ada84e | ||
| 
						 | 
					b25bba50a7 | ||
| 
						 | 
					811930d1e2 | ||
| 
						 | 
					f3db16d6d0 | ||
| 
						 | 
					b3887c818d | ||
| 
						 | 
					f7b73ba280 | ||
| 
						 | 
					5c2bacb322 | ||
| 
						 | 
					657017801b | ||
| 
						 | 
					5e8cfa6b63 | ||
| 
						 | 
					f9bd56215d | ||
| 
						 | 
					aa8b42cbb0 | ||
| 
						 | 
					51f6fabd45 | ||
| 
						 | 
					32ab004f3f | ||
| 
						 | 
					71b27b4bcf | ||
| 
						 | 
					60ca2064bf | ||
| 
						 | 
					5ccd0aa163 | ||
| 
						 | 
					a13b4941cd | ||
| 
						 | 
					482a9e27c9 | ||
| 
						 | 
					f085596b87 | ||
| 
						 | 
					757feab9cd | ||
| 
						 | 
					fffc571453 | ||
| 
						 | 
					6f59a1981d | ||
| 
						 | 
					8bb16f0896 | ||
| 
						 | 
					b454b8d130 | ||
| 
						 | 
					3fc4b799be | ||
| 
						 | 
					9c39d83fe5 | ||
| 
						 | 
					2ce6d9cd73 | ||
| 
						 | 
					e97ccc5cbd | ||
| 
						 | 
					373ef8f468 | ||
| 
						 | 
					513c268b36 | ||
| 
						 | 
					13c4342135 | ||
| 
						 | 
					bbb97dbfda | ||
| 
						 | 
					e0eb544205 | ||
| 
						 | 
					51982010db | ||
| 
						 | 
					dc68afcb87 | ||
| 
						 | 
					bec09b9457 | ||
| 
						 | 
					55c8f74b73 | ||
| 
						 | 
					16ea1dc743 | ||
| 
						 | 
					8c326c8fe2 | ||
| 
						 | 
					2abc9b1f8a | ||
| 
						 | 
					e5f3b0ed26 | ||
| 
						 | 
					bfc5db11da | ||
| 
						 | 
					a0bea9b6e5 | ||
| 
						 | 
					ebda7331a9 | ||
| 
						 | 
					9963cfa417 | ||
| 
						 | 
					4e6a9829cf | ||
| 
						 | 
					b99f4aad4e | ||
| 
						 | 
					7a8e9d95a0 | 
							
								
								
									
										10
									
								
								.github/workflows/app_build.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/workflows/app_build.yml
									
									
									
									
										vendored
									
									
								
							@@ -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
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										6
									
								
								.github/workflows/docker.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/docker.yml
									
									
									
									
										vendored
									
									
								
							@@ -64,7 +64,11 @@ jobs:
 | 
			
		||||
          context: .
 | 
			
		||||
          file: docker/${{ matrix.image }}.Dockerfile
 | 
			
		||||
          platforms: linux/amd64,linux/arm64
 | 
			
		||||
          push: ${{ github.event_name != 'workflow_dispatch' || inputs.push == 'true' }}
 | 
			
		||||
          # Push if:
 | 
			
		||||
          # - Event is not workflow_dispatch OR input 'push' is true
 | 
			
		||||
          # AND
 | 
			
		||||
          # - Event is not pull_request OR the PR is from the same repository (to avoid pushing from forks)
 | 
			
		||||
          push: ${{ (github.event_name != 'workflow_dispatch' || inputs.push == 'true') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
 | 
			
		||||
          tags: ${{ steps.meta.outputs.tags }}
 | 
			
		||||
          labels: ${{ steps.meta.outputs.labels }}
 | 
			
		||||
          cache-from: type=gha,scope=${{ matrix.image }}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -71,6 +71,13 @@ jspm_packages/
 | 
			
		||||
.cache/
 | 
			
		||||
public
 | 
			
		||||
 | 
			
		||||
# Exception: Allow frontend/public/assets for logo files
 | 
			
		||||
!frontend/public/
 | 
			
		||||
!frontend/public/assets/
 | 
			
		||||
!frontend/public/assets/*.png
 | 
			
		||||
!frontend/public/assets/*.svg
 | 
			
		||||
!frontend/public/assets/*.jpg
 | 
			
		||||
 | 
			
		||||
# Storybook build outputs
 | 
			
		||||
.out
 | 
			
		||||
.storybook-out
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										33
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										33
									
								
								README.md
									
									
									
									
									
								
							@@ -4,6 +4,8 @@
 | 
			
		||||
[](https://patchmon.net/discord)
 | 
			
		||||
[](https://github.com/9technologygroup/patchmon.net)
 | 
			
		||||
[](https://github.com/users/9technologygroup/projects/1)
 | 
			
		||||
[](https://docs.patchmon.net/)
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## Please STAR this repo :D
 | 
			
		||||
@@ -12,7 +14,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.
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
## Features
 | 
			
		||||
 | 
			
		||||
@@ -41,6 +43,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
 | 
			
		||||
@@ -62,7 +65,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)
 | 
			
		||||
 | 
			
		||||
@@ -84,7 +87,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 : #####
 | 
			
		||||
@@ -124,22 +127,18 @@ After installation:
 | 
			
		||||
- Database: PostgreSQL
 | 
			
		||||
- System service: systemd-managed backend
 | 
			
		||||
 | 
			
		||||
```mermaid
 | 
			
		||||
flowchart LR
 | 
			
		||||
    A[End Users / Browser<br>Admin UI / Frontend] -- HTTPS --> B[nginx<br>serve FE, proxy API]
 | 
			
		||||
    B -- HTTP --> C["Backend<br>(Node/Express)<br>/api, auth, Prisma"]
 | 
			
		||||
    C -- TCP --> D[PostgreSQL<br>Database]
 | 
			
		||||
 | 
			
		||||
    E["Agents on your servers (Outbound Only)"] -- HTTPS --> F["Backend API<br>(/api/v1)"]
 | 
			
		||||
```
 | 
			
		||||
+----------------------+    HTTPS    +--------------------+    HTTP    +------------------------+    TCP    +---------------+
 | 
			
		||||
|  End Users (Browser) | --------->  |       nginx        | --------> | Backend (Node/Express) | ------> |  PostgreSQL   |
 | 
			
		||||
|  Admin UI / Frontend |            | serve FE, proxy API|           |  /api, auth, Prisma    |         |   Database    |
 | 
			
		||||
+----------------------+            +--------------------+           +------------------------+         +---------------+
 | 
			
		||||
 | 
			
		||||
Agents (Outbound Only)
 | 
			
		||||
+---------------------------+    HTTPS    +------------------------+
 | 
			
		||||
|  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
 | 
			
		||||
 | 
			
		||||
@@ -148,7 +147,7 @@ Operational
 | 
			
		||||
 | 
			
		||||
## Roadmap
 | 
			
		||||
 | 
			
		||||
- Roadmap board: https://github.com/users/9technologygroup/projects/1
 | 
			
		||||
- Roadmap board: https://github.com/orgs/PatchMon/projects/2
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## License
 | 
			
		||||
@@ -271,7 +270,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)
 | 
			
		||||
- **Documentation**: [Coming Soon]
 | 
			
		||||
- **Documentation**: [https://docs.patchmon.net](https://docs.patchmon.net)
 | 
			
		||||
- **Support**: support@patchmon.net
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
@@ -281,6 +280,6 @@ Thank you to all our contributors who help make PatchMon better every day!
 | 
			
		||||
**Made with ❤️ by the PatchMon Team**
 | 
			
		||||
 | 
			
		||||
[](https://patchmon.net/discord)
 | 
			
		||||
[](https://github.com/9technologygroup/patchmon.net)
 | 
			
		||||
[](https://github.com/PatchMon/PatchMon)
 | 
			
		||||
 | 
			
		||||
</div>
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
@@ -686,7 +708,7 @@ get_yum_packages() {
 | 
			
		||||
    done <<< "$upgradable"
 | 
			
		||||
    
 | 
			
		||||
    # Get some installed packages that are up to date
 | 
			
		||||
    local installed=$($package_manager list installed 2>/dev/null | grep -v "^Loaded" | grep -v "^Installed" | head -100)
 | 
			
		||||
    local installed=$($package_manager list installed 2>/dev/null | grep -v "^Loaded" | grep -v "^Installed")
 | 
			
		||||
    
 | 
			
		||||
    while IFS= read -r line; do
 | 
			
		||||
        # Skip empty lines
 | 
			
		||||
@@ -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 <<EOF
 | 
			
		||||
{
 | 
			
		||||
@@ -875,7 +900,8 @@ send_update() {
 | 
			
		||||
    "hostname": "$HOSTNAME",
 | 
			
		||||
    "ip": "$IP_ADDRESS",
 | 
			
		||||
    "architecture": "$ARCHITECTURE",
 | 
			
		||||
    "agentVersion": "$AGENT_VERSION"
 | 
			
		||||
    "agentVersion": "$AGENT_VERSION",
 | 
			
		||||
    "machineId": "$machine_id"
 | 
			
		||||
}
 | 
			
		||||
EOF
 | 
			
		||||
)
 | 
			
		||||
 
 | 
			
		||||
@@ -109,14 +109,39 @@ 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
 | 
			
		||||
 | 
			
		||||
# 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
 | 
			
		||||
 | 
			
		||||
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 ""
 | 
			
		||||
@@ -131,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)"
 | 
			
		||||
@@ -261,6 +358,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"
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										437
									
								
								agents/proxmox_auto_enroll.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										437
									
								
								agents/proxmox_auto_enroll.sh
									
									
									
									
									
										Executable file
									
								
							@@ -0,0 +1,437 @@
 | 
			
		||||
#!/bin/bash
 | 
			
		||||
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="2.0.0"
 | 
			
		||||
echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))"
 | 
			
		||||
 | 
			
		||||
# =============================================================================
 | 
			
		||||
# 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
 | 
			
		||||
# =============================================================================
 | 
			
		||||
 | 
			
		||||
# ===== 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:-}"
 | 
			
		||||
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'
 | 
			
		||||
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"; return 0; }
 | 
			
		||||
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; return 0; }
 | 
			
		||||
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 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"
 | 
			
		||||
╔═══════════════════════════════════════════════════════════════╗
 | 
			
		||||
║                                                               ║
 | 
			
		||||
║   ____       _       _     __  __                            ║
 | 
			
		||||
║  |  _ \ __ _| |_ ___| |__ |  \/  | ___  _ __                ║
 | 
			
		||||
║  | |_) / _` | __/ __| '_ \| |\/| |/ _ \| '_ \               ║
 | 
			
		||||
║  |  __/ (_| | || (__| | | | |  | | (_) | | | |              ║
 | 
			
		||||
║  |_|   \__,_|\__\___|_| |_|_|  |_|\___/|_| |_|              ║
 | 
			
		||||
║                                                               ║
 | 
			
		||||
║         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 ""
 | 
			
		||||
 | 
			
		||||
info "Initializing statistics..."
 | 
			
		||||
# ===== STATISTICS =====
 | 
			
		||||
enrolled_count=0
 | 
			
		||||
skipped_count=0
 | 
			
		||||
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 =====
 | 
			
		||||
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}')
 | 
			
		||||
 | 
			
		||||
    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++)) || true
 | 
			
		||||
        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++)) || true
 | 
			
		||||
        echo ""
 | 
			
		||||
        continue
 | 
			
		||||
    fi
 | 
			
		||||
 | 
			
		||||
    # Get container details
 | 
			
		||||
    debug "  Gathering container information..."
 | 
			
		||||
    hostname=$(timeout 5 pct exec "$vmid" -- hostname 2>/dev/null </dev/null || echo "$name")
 | 
			
		||||
    ip_address=$(timeout 5 pct exec "$vmid" -- hostname -I 2>/dev/null </dev/null | awk '{print $1}' || echo "unknown")
 | 
			
		||||
    os_info=$(timeout 5 pct exec "$vmid" -- cat /etc/os-release 2>/dev/null </dev/null | grep "^PRETTY_NAME=" | cut -d'"' -f2 || echo "unknown")
 | 
			
		||||
    
 | 
			
		||||
    # Get machine ID from container
 | 
			
		||||
    machine_id=$(timeout 5 pct exec "$vmid" -- bash -c "cat /etc/machine-id 2>/dev/null || cat /var/lib/dbus/machine-id 2>/dev/null || echo 'proxmox-lxc-$vmid-'$(cat /proc/sys/kernel/random/uuid)" </dev/null 2>/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"
 | 
			
		||||
        ((enrolled_count++)) || true
 | 
			
		||||
        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\",
 | 
			
		||||
            \"machine_id\": \"$machine_id\",
 | 
			
		||||
            \"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"
 | 
			
		||||
 | 
			
		||||
        # 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 || echo "error")
 | 
			
		||||
        
 | 
			
		||||
        if [[ "$curl_check" == "missing" ]]; then
 | 
			
		||||
            info "  Installing curl in container..."
 | 
			
		||||
            
 | 
			
		||||
            # Detect package manager and install curl
 | 
			
		||||
            curl_install_output=$(timeout 60 pct exec "$vmid" -- bash -c "
 | 
			
		||||
                if command -v apt-get >/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 </dev/null) || true
 | 
			
		||||
            
 | 
			
		||||
            if [[ "$curl_install_output" == *"ERROR: No supported package manager"* ]]; then
 | 
			
		||||
                warn "  ✗ Could not install curl - no supported package manager found"
 | 
			
		||||
                failed_containers["$vmid"]="$friendly_name|No package manager for curl|$curl_install_output"
 | 
			
		||||
                ((failed_count++)) || true
 | 
			
		||||
                echo ""
 | 
			
		||||
                sleep 1
 | 
			
		||||
                continue
 | 
			
		||||
            else
 | 
			
		||||
                info "  ✓ curl installed successfully"
 | 
			
		||||
            fi
 | 
			
		||||
        else
 | 
			
		||||
            info "  ✓ curl already installed"
 | 
			
		||||
        fi
 | 
			
		||||
 | 
			
		||||
        # 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
 | 
			
		||||
        
 | 
			
		||||
        # Download and execute in separate steps to avoid stdin issues with piping
 | 
			
		||||
        install_output=$(timeout 180 pct exec "$vmid" -- bash -c "
 | 
			
		||||
            cd /tmp
 | 
			
		||||
            curl $CURL_FLAGS \
 | 
			
		||||
                -H \"X-API-ID: $api_id\" \
 | 
			
		||||
                -H \"X-API-KEY: $api_key\" \
 | 
			
		||||
                -o patchmon-install.sh \
 | 
			
		||||
                '$install_url' && \
 | 
			
		||||
            bash patchmon-install.sh && \
 | 
			
		||||
            rm -f patchmon-install.sh
 | 
			
		||||
        " 2>&1 </dev/null) || install_exit_code=$?
 | 
			
		||||
 | 
			
		||||
        # Check both exit code AND success message in output for reliability
 | 
			
		||||
        if [[ $install_exit_code -eq 0 ]] || [[ "$install_output" == *"PatchMon Agent installation completed successfully"* ]]; then
 | 
			
		||||
            info "  ✓ Agent installed successfully in $friendly_name"
 | 
			
		||||
            ((enrolled_count++)) || true
 | 
			
		||||
        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
 | 
			
		||||
        fi
 | 
			
		||||
 | 
			
		||||
    elif [[ "$http_code" == "409" ]]; then
 | 
			
		||||
        warn "  ⊘ Host $friendly_name already enrolled - skipping"
 | 
			
		||||
        ((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
 | 
			
		||||
 | 
			
		||||
    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 ""
 | 
			
		||||
 | 
			
		||||
# ===== 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"
 | 
			
		||||
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 </dev/null || true)
 | 
			
		||||
            
 | 
			
		||||
            if [[ $? -eq 0 ]]; then
 | 
			
		||||
                info "  ✓ dpkg fixed successfully"
 | 
			
		||||
                
 | 
			
		||||
                # Retry agent installation
 | 
			
		||||
                info "  Retrying agent installation..."
 | 
			
		||||
                
 | 
			
		||||
                install_exit_code=0
 | 
			
		||||
                install_output=$(timeout 180 pct exec "$vmid" -- bash -c "
 | 
			
		||||
                    cd /tmp
 | 
			
		||||
                    curl $CURL_FLAGS \
 | 
			
		||||
                        -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 && \
 | 
			
		||||
                    rm -f patchmon-install.sh
 | 
			
		||||
                " 2>&1 </dev/null) || install_exit_code=$?
 | 
			
		||||
                
 | 
			
		||||
                if [[ $install_exit_code -eq 0 ]] || [[ "$install_output" == *"PatchMon Agent installation completed successfully"* ]]; then
 | 
			
		||||
                    info "  ✓ Agent installed successfully in $name"
 | 
			
		||||
                    ((recovered_count++)) || true
 | 
			
		||||
                    ((enrolled_count++)) || true
 | 
			
		||||
                    ((failed_count--)) || true
 | 
			
		||||
                else
 | 
			
		||||
                    warn "  ✗ Agent installation still failed (exit: $install_exit_code)"
 | 
			
		||||
                fi
 | 
			
		||||
            else
 | 
			
		||||
                warn "  ✗ Failed to fix dpkg in $name"
 | 
			
		||||
                info "  dpkg output: $dpkg_output"
 | 
			
		||||
            fi
 | 
			
		||||
            
 | 
			
		||||
            echo ""
 | 
			
		||||
        done
 | 
			
		||||
        
 | 
			
		||||
        echo ""
 | 
			
		||||
        info "Recovery complete: $recovered_count container(s) recovered"
 | 
			
		||||
        echo ""
 | 
			
		||||
    fi
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
@@ -1,5 +1,7 @@
 | 
			
		||||
# Database Configuration
 | 
			
		||||
DATABASE_URL="postgresql://patchmon_user:p@tchm0n_p@55@localhost:5432/patchmon_db"
 | 
			
		||||
PM_DB_CONN_MAX_ATTEMPTS=30
 | 
			
		||||
PM_DB_CONN_WAIT_INTERVAL=2
 | 
			
		||||
 | 
			
		||||
# Server Configuration
 | 
			
		||||
PORT=3001
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
 | 
			
		||||
@@ -0,0 +1,20 @@
 | 
			
		||||
-- 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";
 | 
			
		||||
 | 
			
		||||
-- 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");
 | 
			
		||||
 | 
			
		||||
-- 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");
 | 
			
		||||
 | 
			
		||||
@@ -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';
 | 
			
		||||
@@ -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 {
 | 
			
		||||
@@ -59,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
 | 
			
		||||
@@ -91,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 {
 | 
			
		||||
@@ -158,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 {
 | 
			
		||||
@@ -172,22 +181,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 +217,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])
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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: {
 | 
			
		||||
 
 | 
			
		||||
@@ -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",
 | 
			
		||||
	});
 | 
			
		||||
};
 | 
			
		||||
@@ -173,6 +176,8 @@ router.get(
 | 
			
		||||
					id: true,
 | 
			
		||||
					username: true,
 | 
			
		||||
					email: true,
 | 
			
		||||
					first_name: true,
 | 
			
		||||
					last_name: true,
 | 
			
		||||
					role: true,
 | 
			
		||||
					is_active: true,
 | 
			
		||||
					last_login: true,
 | 
			
		||||
@@ -311,6 +316,14 @@ router.put(
 | 
			
		||||
			.isLength({ min: 3 })
 | 
			
		||||
			.withMessage("Username must be at least 3 characters"),
 | 
			
		||||
		body("email").optional().isEmail().withMessage("Valid email is required"),
 | 
			
		||||
		body("first_name")
 | 
			
		||||
			.optional()
 | 
			
		||||
			.isLength({ min: 1 })
 | 
			
		||||
			.withMessage("First name must be at least 1 character"),
 | 
			
		||||
		body("last_name")
 | 
			
		||||
			.optional()
 | 
			
		||||
			.isLength({ min: 1 })
 | 
			
		||||
			.withMessage("Last name must be at least 1 character"),
 | 
			
		||||
		body("role")
 | 
			
		||||
			.optional()
 | 
			
		||||
			.custom(async (value) => {
 | 
			
		||||
@@ -323,10 +336,10 @@ router.put(
 | 
			
		||||
				}
 | 
			
		||||
				return true;
 | 
			
		||||
			}),
 | 
			
		||||
		body("isActive")
 | 
			
		||||
		body("is_active")
 | 
			
		||||
			.optional()
 | 
			
		||||
			.isBoolean()
 | 
			
		||||
			.withMessage("isActive must be a boolean"),
 | 
			
		||||
			.withMessage("is_active must be a boolean"),
 | 
			
		||||
	],
 | 
			
		||||
	async (req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
@@ -337,13 +350,16 @@ router.put(
 | 
			
		||||
				return res.status(400).json({ errors: errors.array() });
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			const { username, email, role, isActive } = req.body;
 | 
			
		||||
			const { username, email, first_name, last_name, role, is_active } =
 | 
			
		||||
				req.body;
 | 
			
		||||
			const updateData = {};
 | 
			
		||||
 | 
			
		||||
			if (username) updateData.username = username;
 | 
			
		||||
			if (email) updateData.email = email;
 | 
			
		||||
			if (first_name !== undefined) updateData.first_name = first_name || null;
 | 
			
		||||
			if (last_name !== undefined) updateData.last_name = last_name || null;
 | 
			
		||||
			if (role) updateData.role = role;
 | 
			
		||||
			if (typeof isActive === "boolean") updateData.is_active = isActive;
 | 
			
		||||
			if (typeof is_active === "boolean") updateData.is_active = is_active;
 | 
			
		||||
 | 
			
		||||
			// Check if user exists
 | 
			
		||||
			const existingUser = await prisma.users.findUnique({
 | 
			
		||||
@@ -378,7 +394,7 @@ router.put(
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Prevent deactivating the last admin
 | 
			
		||||
			if (isActive === false && existingUser.role === "admin") {
 | 
			
		||||
			if (is_active === false && existingUser.role === "admin") {
 | 
			
		||||
				const adminCount = await prisma.users.count({
 | 
			
		||||
					where: {
 | 
			
		||||
						role: "admin",
 | 
			
		||||
@@ -401,6 +417,8 @@ router.put(
 | 
			
		||||
					id: true,
 | 
			
		||||
					username: true,
 | 
			
		||||
					email: true,
 | 
			
		||||
					first_name: true,
 | 
			
		||||
					last_name: true,
 | 
			
		||||
					role: true,
 | 
			
		||||
					is_active: true,
 | 
			
		||||
					last_login: true,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										745
									
								
								backend/src/routes/autoEnrollmentRoutes.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										745
									
								
								backend/src/routes/autoEnrollmentRoutes.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,745 @@
 | 
			
		||||
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 (_) {}
 | 
			
		||||
 | 
			
		||||
		// 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"}"
 | 
			
		||||
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
		// 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("machine_id")
 | 
			
		||||
			.isLength({ min: 1, max: 255 })
 | 
			
		||||
			.withMessage("Machine ID 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, 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 by machine_id (not hostname)
 | 
			
		||||
			const existing_host = await prisma.hosts.findUnique({
 | 
			
		||||
				where: { machine_id },
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			if (existing_host) {
 | 
			
		||||
				return res.status(409).json({
 | 
			
		||||
					error: "Host already exists",
 | 
			
		||||
					host_id: existing_host.id,
 | 
			
		||||
					api_id: existing_host.api_id,
 | 
			
		||||
					machine_id: existing_host.machine_id,
 | 
			
		||||
					friendly_name: existing_host.friendly_name,
 | 
			
		||||
					message:
 | 
			
		||||
						"This machine is already enrolled in PatchMon (matched by machine ID)",
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Create host
 | 
			
		||||
			const host = await prisma.hosts.create({
 | 
			
		||||
				data: {
 | 
			
		||||
					id: uuidv4(),
 | 
			
		||||
					machine_id,
 | 
			
		||||
					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, machine_id } = host_data;
 | 
			
		||||
 | 
			
		||||
					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: { machine_id },
 | 
			
		||||
					});
 | 
			
		||||
 | 
			
		||||
					if (existing_host) {
 | 
			
		||||
						results.skipped.push({
 | 
			
		||||
							friendly_name,
 | 
			
		||||
							machine_id,
 | 
			
		||||
							reason: "Machine already enrolled",
 | 
			
		||||
							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(),
 | 
			
		||||
							machine_id,
 | 
			
		||||
							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;
 | 
			
		||||
@@ -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,
 | 
			
		||||
 
 | 
			
		||||
@@ -172,15 +172,6 @@ router.post(
 | 
			
		||||
			// Generate unique API credentials for this host
 | 
			
		||||
			const { apiId, apiKey } = generateApiCredentials();
 | 
			
		||||
 | 
			
		||||
			// Check if host already exists
 | 
			
		||||
			const existingHost = await prisma.hosts.findUnique({
 | 
			
		||||
				where: { friendly_name: friendly_name },
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			if (existingHost) {
 | 
			
		||||
				return res.status(409).json({ error: "Host already exists" });
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// If hostGroupId is provided, verify the group exists
 | 
			
		||||
			if (hostGroupId) {
 | 
			
		||||
				const hostGroup = await prisma.host_groups.findUnique({
 | 
			
		||||
@@ -196,6 +187,7 @@ router.post(
 | 
			
		||||
			const host = await prisma.hosts.create({
 | 
			
		||||
				data: {
 | 
			
		||||
					id: uuidv4(),
 | 
			
		||||
					machine_id: `pending-${uuidv4()}`, // Temporary placeholder until agent connects with real machine_id
 | 
			
		||||
					friendly_name: friendly_name,
 | 
			
		||||
					os_type: "unknown", // Will be updated when agent connects
 | 
			
		||||
					os_version: "unknown", // Will be updated when agent connects
 | 
			
		||||
@@ -321,6 +313,10 @@ router.post(
 | 
			
		||||
			.optional()
 | 
			
		||||
			.isArray()
 | 
			
		||||
			.withMessage("Load average must be an array"),
 | 
			
		||||
		body("machineId")
 | 
			
		||||
			.optional()
 | 
			
		||||
			.isString()
 | 
			
		||||
			.withMessage("Machine ID must be a string"),
 | 
			
		||||
	],
 | 
			
		||||
	async (req, res) => {
 | 
			
		||||
		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;
 | 
			
		||||
@@ -1126,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"}"
 | 
			
		||||
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
@@ -1151,6 +1156,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 {
 | 
			
		||||
 
 | 
			
		||||
@@ -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,18 @@ 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,
 | 
			
		||||
						needsUpdate: hp.needs_update,
 | 
			
		||||
						isSecurityUpdate: hp.is_security_update,
 | 
			
		||||
					})),
 | 
			
		||||
					stats: {
 | 
			
		||||
						totalInstalls: pkg._count.hostPackages,
 | 
			
		||||
						totalInstalls: pkg._count.host_packages,
 | 
			
		||||
						updatesNeeded: updatesCount,
 | 
			
		||||
						securityUpdates: securityCount,
 | 
			
		||||
					},
 | 
			
		||||
@@ -160,19 +163,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",
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
@@ -185,25 +188,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;
 | 
			
		||||
		}, {});
 | 
			
		||||
@@ -230,4 +233,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;
 | 
			
		||||
 
 | 
			
		||||
@@ -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",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										249
									
								
								backend/src/routes/searchRoutes.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										249
									
								
								backend/src/routes/searchRoutes.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,249 @@
 | 
			
		||||
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" } },
 | 
			
		||||
							{ machine_id: { contains: searchTerm, mode: "insensitive" } },
 | 
			
		||||
						],
 | 
			
		||||
					},
 | 
			
		||||
					select: {
 | 
			
		||||
						id: true,
 | 
			
		||||
						machine_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;
 | 
			
		||||
@@ -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;
 | 
			
		||||
 
 | 
			
		||||
@@ -2,36 +2,211 @@ 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;
 | 
			
		||||
			}
 | 
			
		||||
		} catch (packageError) {
 | 
			
		||||
			console.warn(
 | 
			
		||||
				"Could not read version from package.json, using fallback:",
 | 
			
		||||
				packageError.message,
 | 
			
		||||
			);
 | 
			
		||||
		}
 | 
			
		||||
		// 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);
 | 
			
		||||
 | 
			
		||||
		// 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,
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error("Error getting current version:", error);
 | 
			
		||||
@@ -44,119 +219,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 +241,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) {
 | 
			
		||||
 
 | 
			
		||||
@@ -60,6 +60,8 @@ const {
 | 
			
		||||
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");
 | 
			
		||||
@@ -414,6 +416,12 @@ 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);
 | 
			
		||||
app.use(
 | 
			
		||||
	`/api/${apiVersion}/auto-enrollment`,
 | 
			
		||||
	authLimiter,
 | 
			
		||||
	autoEnrollmentRoutes,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Error handling middleware
 | 
			
		||||
app.use((err, _req, res, _next) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -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:")) {
 | 
			
		||||
@@ -128,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,
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
@@ -147,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,
 | 
			
		||||
						},
 | 
			
		||||
					});
 | 
			
		||||
				}
 | 
			
		||||
@@ -241,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}`,
 | 
			
		||||
				);
 | 
			
		||||
 
 | 
			
		||||
@@ -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();
 | 
			
		||||
@@ -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(
 | 
			
		||||
 
 | 
			
		||||
@@ -6,14 +6,21 @@ PatchMon is a containerised application that monitors system patches and updates
 | 
			
		||||
 | 
			
		||||
- **Database**: PostgreSQL 17
 | 
			
		||||
- **Backend**: Node.js API server
 | 
			
		||||
- **Frontend**: React application served via Nginx
 | 
			
		||||
- **Frontend**: React application served via NGINX
 | 
			
		||||
 | 
			
		||||
## Images
 | 
			
		||||
 | 
			
		||||
- **Backend**: [ghcr.io/patchmon/patchmon-backend:latest](https://github.com/patchmon/patchmon.net/pkgs/container/patchmon-backend)
 | 
			
		||||
- **Frontend**: [ghcr.io/patchmon/patchmon-frontend:latest](https://github.com/patchmon/patchmon.net/pkgs/container/patchmon-frontend)
 | 
			
		||||
- **Backend**: [ghcr.io/patchmon/patchmon-backend](https://github.com/patchmon/patchmon.net/pkgs/container/patchmon-backend)
 | 
			
		||||
- **Frontend**: [ghcr.io/patchmon/patchmon-frontend](https://github.com/patchmon/patchmon.net/pkgs/container/patchmon-frontend)
 | 
			
		||||
 | 
			
		||||
Version tags are also available (e.g. `1.2.3`) for both of these images.
 | 
			
		||||
### Tags
 | 
			
		||||
 | 
			
		||||
- `latest`: The latest stable release of PatchMon
 | 
			
		||||
- `x.y.z`: Full version tags (e.g. `1.2.3`) - Use this for exact version pinning.
 | 
			
		||||
- `x.y`: Minor version tags (e.g. `1.2`) - Use this to get the latest patch release in a minor version series.
 | 
			
		||||
- `x`: Major version tags (e.g. `1`) - Use this to get the latest minor and patch release in a major version series.
 | 
			
		||||
 | 
			
		||||
These tags are available for both backend and frontend images as they are versioned together.
 | 
			
		||||
 | 
			
		||||
## Quick Start
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,7 @@
 | 
			
		||||
<html lang="en">
 | 
			
		||||
  <head>
 | 
			
		||||
    <meta charset="UTF-8" />
 | 
			
		||||
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
 | 
			
		||||
    <link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
 | 
			
		||||
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 | 
			
		||||
    <title>PatchMon - Linux Patch Monitoring Dashboard</title>
 | 
			
		||||
    <link rel="preconnect" href="https://fonts.googleapis.com">
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								frontend/public/assets/favicon.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/public/assets/favicon.svg
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="500" zoomAndPan="magnify" viewBox="0 0 375 374.999991" height="500" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><g/><clipPath id="d62632d413"><path d="M 29 28 L 304 28 L 304 350 L 29 350 Z M 29 28 " clip-rule="nonzero"/></clipPath><clipPath id="ecc8b4d8ed"><path d="M 187.496094 -39.996094 L 416.601562 189.105469 L 187.496094 418.207031 L -41.605469 189.105469 Z M 187.496094 -39.996094 " clip-rule="nonzero"/></clipPath><clipPath id="3016db942f"><path d="M 187.496094 -39.996094 L 416.601562 189.105469 L 187.496094 418.207031 L -41.605469 189.105469 Z M 187.496094 -39.996094 " clip-rule="nonzero"/></clipPath><clipPath id="029f8ae6a8"><path d="M 29 28 L 304 28 L 304 350 L 29 350 Z M 29 28 " clip-rule="nonzero"/></clipPath><clipPath id="2d374b5e76"><path d="M 187.496094 -39.996094 L 416.601562 189.105469 L 187.496094 418.207031 L -41.605469 189.105469 Z M 187.496094 -39.996094 " clip-rule="nonzero"/></clipPath><clipPath id="544d823606"><path d="M 187.496094 -39.996094 L 416.601562 189.105469 L 187.496094 418.207031 L -41.605469 189.105469 Z M 187.496094 -39.996094 " clip-rule="nonzero"/></clipPath><clipPath id="b88a276116"><path d="M 187.496094 -39.996094 L 416.601562 189.105469 L 187.496094 418.207031 L -41.605469 189.105469 Z M 187.496094 -39.996094 " clip-rule="nonzero"/></clipPath><clipPath id="98c26e11a4"><rect x="0" width="103" y="0" height="208"/></clipPath></defs><g clip-path="url(#d62632d413)"><g clip-path="url(#ecc8b4d8ed)"><g clip-path="url(#3016db942f)"><path fill="#ff751f" d="M 303.214844 302.761719 C 280.765625 325.214844 252.160156 340.503906 221.015625 346.699219 C 189.875 352.890625 157.59375 349.714844 128.261719 337.5625 C 98.925781 325.410156 73.851562 304.835938 56.210938 278.433594 C 38.570312 252.03125 29.15625 220.992188 29.15625 189.242188 C 29.15625 157.488281 38.570312 126.449219 56.210938 100.050781 C 73.851562 73.648438 98.925781 53.070312 128.261719 40.921875 C 157.59375 28.769531 189.875 25.589844 221.015625 31.785156 C 252.160156 37.980469 280.765625 53.269531 303.214844 75.722656 L 189.695312 189.242188 Z M 303.214844 302.761719 " fill-opacity="1" fill-rule="nonzero"/></g></g></g><g clip-path="url(#029f8ae6a8)"><g clip-path="url(#2d374b5e76)"><g clip-path="url(#544d823606)"><g clip-path="url(#b88a276116)"><path fill="#61b33a" d="M 303.144531 302.550781 C 280.707031 324.988281 252.117188 340.269531 220.996094 346.460938 C 189.875 352.652344 157.613281 349.472656 128.296875 337.332031 C 98.980469 325.1875 73.921875 304.621094 56.292969 278.238281 C 38.664062 251.851562 29.253906 220.832031 29.253906 189.101562 C 29.253906 157.367188 38.664062 126.347656 56.292969 99.964844 C 73.921875 73.578125 98.980469 53.015625 128.296875 40.871094 C 157.613281 28.726562 189.875 25.550781 220.996094 31.742188 C 252.117188 37.929688 280.707031 53.210938 303.144531 75.652344 L 189.695312 189.101562 Z M 303.144531 302.550781 " fill-opacity="1" fill-rule="nonzero"/></g></g></g></g><g transform="matrix(1, 0, 0, 1, 136, 0)"><g clip-path="url(#98c26e11a4)"><g fill="#ff751f" fill-opacity="1"><g transform="translate(0.457164, 116.403543)"><g><path d="M 19.734375 -18.71875 C 19.734375 -21.664062 20.015625 -24.441406 20.578125 -27.046875 C 21.148438 -29.660156 22.0625 -32.210938 23.3125 -34.703125 C 24.5625 -37.203125 26.207031 -39.359375 28.25 -41.171875 C 33.6875 -47.066406 41.285156 -50.015625 51.046875 -50.015625 C 59.210938 -50.015625 66.46875 -46.953125 72.8125 -40.828125 C 79.164062 -34.703125 82.34375 -27.332031 82.34375 -18.71875 C 82.34375 -9.414062 79.28125 -1.925781 73.15625 3.75 C 67.257812 9.644531 59.890625 12.59375 51.046875 12.59375 C 42.648438 12.59375 35.332031 9.472656 29.09375 3.234375 C 22.851562 -3.003906 19.734375 -10.320312 19.734375 -18.71875 Z M 19.734375 -18.71875 "/></g></g></g></g></g></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 3.8 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								frontend/public/assets/logo_dark.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend/public/assets/logo_dark.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 18 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								frontend/public/assets/logo_light.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend/public/assets/logo_light.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 24 KiB  | 
@@ -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() {
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/settings/branding"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<SettingsServerConfig />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/settings/agent-version"
 | 
			
		||||
				element={
 | 
			
		||||
@@ -329,7 +340,9 @@ function App() {
 | 
			
		||||
		<ThemeProvider>
 | 
			
		||||
			<AuthProvider>
 | 
			
		||||
				<UpdateNotificationProvider>
 | 
			
		||||
					<AppRoutes />
 | 
			
		||||
					<LogoProvider>
 | 
			
		||||
						<AppRoutes />
 | 
			
		||||
					</LogoProvider>
 | 
			
		||||
				</UpdateNotificationProvider>
 | 
			
		||||
			</AuthProvider>
 | 
			
		||||
		</ThemeProvider>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										16
									
								
								frontend/src/components/DiscordIcon.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								frontend/src/components/DiscordIcon.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
			
		||||
const DiscordIcon = ({ className = "h-5 w-5" }) => {
 | 
			
		||||
	return (
 | 
			
		||||
		<svg
 | 
			
		||||
			viewBox="0 0 24 24"
 | 
			
		||||
			fill="currentColor"
 | 
			
		||||
			className={className}
 | 
			
		||||
			xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
			aria-label="Discord"
 | 
			
		||||
		>
 | 
			
		||||
			<title>Discord</title>
 | 
			
		||||
			<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z" />
 | 
			
		||||
		</svg>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default DiscordIcon;
 | 
			
		||||
							
								
								
									
										428
									
								
								frontend/src/components/GlobalSearch.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										428
									
								
								frontend/src/components/GlobalSearch.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -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 <Server className="h-4 w-4 text-blue-500" />;
 | 
			
		||||
			case "package":
 | 
			
		||||
				return <Package className="h-4 w-4 text-green-500" />;
 | 
			
		||||
			case "repository":
 | 
			
		||||
				return <GitBranch className="h-4 w-4 text-purple-500" />;
 | 
			
		||||
			case "user":
 | 
			
		||||
				return <User className="h-4 w-4 text-orange-500" />;
 | 
			
		||||
			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 (
 | 
			
		||||
		<div ref={searchRef} className="relative w-full max-w-sm">
 | 
			
		||||
			<div className="relative">
 | 
			
		||||
				<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
 | 
			
		||||
					<Search className="h-5 w-5 text-secondary-400" />
 | 
			
		||||
				</div>
 | 
			
		||||
				<input
 | 
			
		||||
					ref={inputRef}
 | 
			
		||||
					type="text"
 | 
			
		||||
					className="block w-full rounded-lg border border-secondary-200 bg-white py-2 pl-10 pr-10 text-sm text-secondary-900 placeholder-secondary-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-secondary-600 dark:bg-secondary-700 dark:text-white dark:placeholder-secondary-400"
 | 
			
		||||
					placeholder="Search hosts, packages, repos, users..."
 | 
			
		||||
					value={query}
 | 
			
		||||
					onChange={handleInputChange}
 | 
			
		||||
					onKeyDown={handleKeyDown}
 | 
			
		||||
					onFocus={() => {
 | 
			
		||||
						if (query && results) setIsOpen(true);
 | 
			
		||||
					}}
 | 
			
		||||
				/>
 | 
			
		||||
				{query && (
 | 
			
		||||
					<button
 | 
			
		||||
						type="button"
 | 
			
		||||
						onClick={handleClear}
 | 
			
		||||
						className="absolute inset-y-0 right-0 flex items-center pr-3 text-secondary-400 hover:text-secondary-600"
 | 
			
		||||
					>
 | 
			
		||||
						<X className="h-4 w-4" />
 | 
			
		||||
					</button>
 | 
			
		||||
				)}
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			{/* Dropdown Results */}
 | 
			
		||||
			{isOpen && (
 | 
			
		||||
				<div className="absolute z-50 mt-2 w-full rounded-lg border border-secondary-200 bg-white shadow-lg dark:border-secondary-600 dark:bg-secondary-800">
 | 
			
		||||
					{isLoading ? (
 | 
			
		||||
						<div className="px-4 py-2 text-center text-sm text-secondary-500">
 | 
			
		||||
							Searching...
 | 
			
		||||
						</div>
 | 
			
		||||
					) : hasResults ? (
 | 
			
		||||
						<div className="max-h-96 overflow-y-auto">
 | 
			
		||||
							{/* Hosts */}
 | 
			
		||||
							{results.hosts?.length > 0 && (
 | 
			
		||||
								<div>
 | 
			
		||||
									<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
 | 
			
		||||
										Hosts
 | 
			
		||||
									</div>
 | 
			
		||||
									{results.hosts.map((host, _idx) => {
 | 
			
		||||
										const display = getResultDisplay(host);
 | 
			
		||||
										const globalIdx = navigableResults.findIndex(
 | 
			
		||||
											(r) => r.id === host.id && r.type === "host",
 | 
			
		||||
										);
 | 
			
		||||
										return (
 | 
			
		||||
											<button
 | 
			
		||||
												type="button"
 | 
			
		||||
												key={host.id}
 | 
			
		||||
												onClick={() => handleResultClick(host)}
 | 
			
		||||
												className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
 | 
			
		||||
													globalIdx === selectedIndex
 | 
			
		||||
														? "bg-primary-50 dark:bg-primary-900/20"
 | 
			
		||||
														: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
 | 
			
		||||
												}`}
 | 
			
		||||
											>
 | 
			
		||||
												{getResultIcon("host")}
 | 
			
		||||
												<div className="flex-1 min-w-0 flex items-center gap-2">
 | 
			
		||||
													<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
 | 
			
		||||
														{display.primary}
 | 
			
		||||
													</span>
 | 
			
		||||
													<span className="text-xs text-secondary-400">•</span>
 | 
			
		||||
													<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
 | 
			
		||||
														{display.secondary}
 | 
			
		||||
													</span>
 | 
			
		||||
												</div>
 | 
			
		||||
												<div className="flex-shrink-0 text-xs text-secondary-400">
 | 
			
		||||
													{host.os_type}
 | 
			
		||||
												</div>
 | 
			
		||||
											</button>
 | 
			
		||||
										);
 | 
			
		||||
									})}
 | 
			
		||||
								</div>
 | 
			
		||||
							)}
 | 
			
		||||
 | 
			
		||||
							{/* Packages */}
 | 
			
		||||
							{results.packages?.length > 0 && (
 | 
			
		||||
								<div>
 | 
			
		||||
									<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
 | 
			
		||||
										Packages
 | 
			
		||||
									</div>
 | 
			
		||||
									{results.packages.map((pkg, _idx) => {
 | 
			
		||||
										const display = getResultDisplay(pkg);
 | 
			
		||||
										const globalIdx = navigableResults.findIndex(
 | 
			
		||||
											(r) => r.id === pkg.id && r.type === "package",
 | 
			
		||||
										);
 | 
			
		||||
										return (
 | 
			
		||||
											<button
 | 
			
		||||
												type="button"
 | 
			
		||||
												key={pkg.id}
 | 
			
		||||
												onClick={() => handleResultClick(pkg)}
 | 
			
		||||
												className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
 | 
			
		||||
													globalIdx === selectedIndex
 | 
			
		||||
														? "bg-primary-50 dark:bg-primary-900/20"
 | 
			
		||||
														: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
 | 
			
		||||
												}`}
 | 
			
		||||
											>
 | 
			
		||||
												{getResultIcon("package")}
 | 
			
		||||
												<div className="flex-1 min-w-0 flex items-center gap-2">
 | 
			
		||||
													<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
 | 
			
		||||
														{display.primary}
 | 
			
		||||
													</span>
 | 
			
		||||
													{display.secondary && (
 | 
			
		||||
														<>
 | 
			
		||||
															<span className="text-xs text-secondary-400">
 | 
			
		||||
																•
 | 
			
		||||
															</span>
 | 
			
		||||
															<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
 | 
			
		||||
																{display.secondary}
 | 
			
		||||
															</span>
 | 
			
		||||
														</>
 | 
			
		||||
													)}
 | 
			
		||||
												</div>
 | 
			
		||||
												<div className="flex-shrink-0 text-xs text-secondary-400">
 | 
			
		||||
													{pkg.host_count} hosts
 | 
			
		||||
												</div>
 | 
			
		||||
											</button>
 | 
			
		||||
										);
 | 
			
		||||
									})}
 | 
			
		||||
								</div>
 | 
			
		||||
							)}
 | 
			
		||||
 | 
			
		||||
							{/* Repositories */}
 | 
			
		||||
							{results.repositories?.length > 0 && (
 | 
			
		||||
								<div>
 | 
			
		||||
									<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
 | 
			
		||||
										Repositories
 | 
			
		||||
									</div>
 | 
			
		||||
									{results.repositories.map((repo, _idx) => {
 | 
			
		||||
										const display = getResultDisplay(repo);
 | 
			
		||||
										const globalIdx = navigableResults.findIndex(
 | 
			
		||||
											(r) => r.id === repo.id && r.type === "repository",
 | 
			
		||||
										);
 | 
			
		||||
										return (
 | 
			
		||||
											<button
 | 
			
		||||
												type="button"
 | 
			
		||||
												key={repo.id}
 | 
			
		||||
												onClick={() => handleResultClick(repo)}
 | 
			
		||||
												className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
 | 
			
		||||
													globalIdx === selectedIndex
 | 
			
		||||
														? "bg-primary-50 dark:bg-primary-900/20"
 | 
			
		||||
														: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
 | 
			
		||||
												}`}
 | 
			
		||||
											>
 | 
			
		||||
												{getResultIcon("repository")}
 | 
			
		||||
												<div className="flex-1 min-w-0 flex items-center gap-2">
 | 
			
		||||
													<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
 | 
			
		||||
														{display.primary}
 | 
			
		||||
													</span>
 | 
			
		||||
													<span className="text-xs text-secondary-400">•</span>
 | 
			
		||||
													<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
 | 
			
		||||
														{display.secondary}
 | 
			
		||||
													</span>
 | 
			
		||||
												</div>
 | 
			
		||||
												<div className="flex-shrink-0 text-xs text-secondary-400">
 | 
			
		||||
													{repo.host_count} hosts
 | 
			
		||||
												</div>
 | 
			
		||||
											</button>
 | 
			
		||||
										);
 | 
			
		||||
									})}
 | 
			
		||||
								</div>
 | 
			
		||||
							)}
 | 
			
		||||
 | 
			
		||||
							{/* Users */}
 | 
			
		||||
							{results.users?.length > 0 && (
 | 
			
		||||
								<div>
 | 
			
		||||
									<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
 | 
			
		||||
										Users
 | 
			
		||||
									</div>
 | 
			
		||||
									{results.users.map((user, _idx) => {
 | 
			
		||||
										const display = getResultDisplay(user);
 | 
			
		||||
										const globalIdx = navigableResults.findIndex(
 | 
			
		||||
											(r) => r.id === user.id && r.type === "user",
 | 
			
		||||
										);
 | 
			
		||||
										return (
 | 
			
		||||
											<button
 | 
			
		||||
												type="button"
 | 
			
		||||
												key={user.id}
 | 
			
		||||
												onClick={() => handleResultClick(user)}
 | 
			
		||||
												className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
 | 
			
		||||
													globalIdx === selectedIndex
 | 
			
		||||
														? "bg-primary-50 dark:bg-primary-900/20"
 | 
			
		||||
														: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
 | 
			
		||||
												}`}
 | 
			
		||||
											>
 | 
			
		||||
												{getResultIcon("user")}
 | 
			
		||||
												<div className="flex-1 min-w-0 flex items-center gap-2">
 | 
			
		||||
													<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
 | 
			
		||||
														{display.primary}
 | 
			
		||||
													</span>
 | 
			
		||||
													<span className="text-xs text-secondary-400">•</span>
 | 
			
		||||
													<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
 | 
			
		||||
														{display.secondary}
 | 
			
		||||
													</span>
 | 
			
		||||
												</div>
 | 
			
		||||
												<div className="flex-shrink-0 text-xs text-secondary-400">
 | 
			
		||||
													{user.role}
 | 
			
		||||
												</div>
 | 
			
		||||
											</button>
 | 
			
		||||
										);
 | 
			
		||||
									})}
 | 
			
		||||
								</div>
 | 
			
		||||
							)}
 | 
			
		||||
						</div>
 | 
			
		||||
					) : query.trim() ? (
 | 
			
		||||
						<div className="px-4 py-2 text-center text-sm text-secondary-500">
 | 
			
		||||
							No results found for "{query}"
 | 
			
		||||
						</div>
 | 
			
		||||
					) : null}
 | 
			
		||||
				</div>
 | 
			
		||||
			)}
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default GlobalSearch;
 | 
			
		||||
@@ -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,6 +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 }) => {
 | 
			
		||||
@@ -292,7 +295,7 @@ const Layout = ({ children }) => {
 | 
			
		||||
					onClick={() => setSidebarOpen(false)}
 | 
			
		||||
					aria-label="Close sidebar"
 | 
			
		||||
				/>
 | 
			
		||||
				<div className="relative flex w-full max-w-xs flex-col bg-white pb-4 pt-5 shadow-xl">
 | 
			
		||||
				<div className="relative flex w-full max-w-[280px] flex-col bg-white pb-4 pt-5 shadow-xl">
 | 
			
		||||
					<div className="absolute right-0 top-0 -mr-12 pt-2">
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
@@ -302,13 +305,10 @@ const Layout = ({ children }) => {
 | 
			
		||||
							<X className="h-6 w-6 text-white" />
 | 
			
		||||
						</button>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div className="flex flex-shrink-0 items-center px-4">
 | 
			
		||||
						<div className="flex items-center">
 | 
			
		||||
							<Shield className="h-8 w-8 text-primary-600" />
 | 
			
		||||
							<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">
 | 
			
		||||
								PatchMon
 | 
			
		||||
							</h1>
 | 
			
		||||
						</div>
 | 
			
		||||
					<div className="flex flex-shrink-0 items-center justify-center px-4">
 | 
			
		||||
						<Link to="/" className="flex items-center">
 | 
			
		||||
							<Logo className="h-10 w-auto" alt="PatchMon Logo" />
 | 
			
		||||
						</Link>
 | 
			
		||||
					</div>
 | 
			
		||||
					<nav className="mt-8 flex-1 space-y-6 px-2">
 | 
			
		||||
						{/* Show message for users with very limited permissions */}
 | 
			
		||||
@@ -344,7 +344,7 @@ const Layout = ({ children }) => {
 | 
			
		||||
								// Section with items
 | 
			
		||||
								return (
 | 
			
		||||
									<div key={item.section}>
 | 
			
		||||
										<h3 className="text-xs font-semibold text-secondary-500 uppercase tracking-wider mb-2 px-2">
 | 
			
		||||
										<h3 className="text-xs font-semibold text-secondary-500 uppercase tracking-wider mb-2">
 | 
			
		||||
											{item.section}
 | 
			
		||||
										</h3>
 | 
			
		||||
										<div className="space-y-1">
 | 
			
		||||
@@ -464,8 +464,8 @@ const Layout = ({ children }) => {
 | 
			
		||||
 | 
			
		||||
			{/* Desktop sidebar */}
 | 
			
		||||
			<div
 | 
			
		||||
				className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 ${
 | 
			
		||||
					sidebarCollapsed ? "lg:w-16" : "lg:w-64"
 | 
			
		||||
				className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 relative ${
 | 
			
		||||
					sidebarCollapsed ? "lg:w-16" : "lg:w-56"
 | 
			
		||||
				} bg-white dark:bg-secondary-800`}
 | 
			
		||||
			>
 | 
			
		||||
				<div
 | 
			
		||||
@@ -474,38 +474,37 @@ const Layout = ({ children }) => {
 | 
			
		||||
					}`}
 | 
			
		||||
				>
 | 
			
		||||
					<div
 | 
			
		||||
						className={`flex h-16 shrink-0 items-center border-b border-secondary-200 ${
 | 
			
		||||
							sidebarCollapsed ? "justify-center" : "justify-between"
 | 
			
		||||
						className={`flex h-16 shrink-0 items-center border-b border-secondary-200 dark:border-secondary-600 ${
 | 
			
		||||
							sidebarCollapsed ? "justify-center" : "justify-center"
 | 
			
		||||
						}`}
 | 
			
		||||
					>
 | 
			
		||||
						{sidebarCollapsed ? (
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
 | 
			
		||||
								className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
 | 
			
		||||
								title="Expand sidebar"
 | 
			
		||||
							>
 | 
			
		||||
								<ChevronRight className="h-5 w-5 text-secondary-700 dark:text-white" />
 | 
			
		||||
							</button>
 | 
			
		||||
							<Link to="/" className="flex items-center">
 | 
			
		||||
								<img
 | 
			
		||||
									src="/assets/favicon.svg"
 | 
			
		||||
									alt="PatchMon"
 | 
			
		||||
									className="h-12 w-12 object-contain"
 | 
			
		||||
								/>
 | 
			
		||||
							</Link>
 | 
			
		||||
						) : (
 | 
			
		||||
							<>
 | 
			
		||||
								<div className="flex items-center">
 | 
			
		||||
									<Shield className="h-8 w-8 text-primary-600" />
 | 
			
		||||
									<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">
 | 
			
		||||
										PatchMon
 | 
			
		||||
									</h1>
 | 
			
		||||
								</div>
 | 
			
		||||
								<button
 | 
			
		||||
									type="button"
 | 
			
		||||
									onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
 | 
			
		||||
									className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
 | 
			
		||||
									title="Collapse sidebar"
 | 
			
		||||
								>
 | 
			
		||||
									<ChevronLeft className="h-5 w-5 text-secondary-700 dark:text-white" />
 | 
			
		||||
								</button>
 | 
			
		||||
							</>
 | 
			
		||||
							<Link to="/" className="flex items-center">
 | 
			
		||||
								<Logo className="h-10 w-auto" alt="PatchMon Logo" />
 | 
			
		||||
							</Link>
 | 
			
		||||
						)}
 | 
			
		||||
					</div>
 | 
			
		||||
					{/* Collapse/Expand button on border */}
 | 
			
		||||
					<button
 | 
			
		||||
						type="button"
 | 
			
		||||
						onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
 | 
			
		||||
						className="absolute top-5 -right-3 z-10 flex items-center justify-center w-6 h-6 rounded-full bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 shadow-md hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
 | 
			
		||||
						title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
 | 
			
		||||
					>
 | 
			
		||||
						{sidebarCollapsed ? (
 | 
			
		||||
							<ChevronRight className="h-4 w-4 text-secondary-700 dark:text-white" />
 | 
			
		||||
						) : (
 | 
			
		||||
							<ChevronLeft className="h-4 w-4 text-secondary-700 dark:text-white" />
 | 
			
		||||
						)}
 | 
			
		||||
					</button>
 | 
			
		||||
					<nav className="flex flex-1 flex-col">
 | 
			
		||||
						<ul className="flex flex-1 flex-col gap-y-6">
 | 
			
		||||
							{/* Show message for users with very limited permissions */}
 | 
			
		||||
@@ -523,7 +522,10 @@ const Layout = ({ children }) => {
 | 
			
		||||
								if (item.name) {
 | 
			
		||||
									// Single item (Dashboard)
 | 
			
		||||
									return (
 | 
			
		||||
										<li key={item.name}>
 | 
			
		||||
										<li
 | 
			
		||||
											key={item.name}
 | 
			
		||||
											className={sidebarCollapsed ? "" : "-mx-2"}
 | 
			
		||||
										>
 | 
			
		||||
											<Link
 | 
			
		||||
												to={item.href}
 | 
			
		||||
												className={`group flex gap-x-3 rounded-md text-sm leading-6 font-semibold transition-all duration-200 ${
 | 
			
		||||
@@ -547,7 +549,7 @@ const Layout = ({ children }) => {
 | 
			
		||||
									return (
 | 
			
		||||
										<li key={item.section}>
 | 
			
		||||
											{!sidebarCollapsed && (
 | 
			
		||||
												<h3 className="text-xs font-semibold text-secondary-500 dark:text-secondary-300 uppercase tracking-wider mb-2 px-2">
 | 
			
		||||
												<h3 className="text-xs font-semibold text-secondary-500 dark:text-secondary-300 uppercase tracking-wider mb-2">
 | 
			
		||||
													{item.section}
 | 
			
		||||
												</h3>
 | 
			
		||||
											)}
 | 
			
		||||
@@ -849,7 +851,7 @@ const Layout = ({ children }) => {
 | 
			
		||||
			{/* Main content */}
 | 
			
		||||
			<div
 | 
			
		||||
				className={`flex flex-col min-h-screen transition-all duration-300 ${
 | 
			
		||||
					sidebarCollapsed ? "lg:pl-16" : "lg:pl-64"
 | 
			
		||||
					sidebarCollapsed ? "lg:pl-16" : "lg:pl-56"
 | 
			
		||||
				}`}
 | 
			
		||||
			>
 | 
			
		||||
				{/* Top bar */}
 | 
			
		||||
@@ -866,16 +868,22 @@ const Layout = ({ children }) => {
 | 
			
		||||
					<div className="h-6 w-px bg-secondary-200 lg:hidden" />
 | 
			
		||||
 | 
			
		||||
					<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
 | 
			
		||||
						<div className="relative flex flex-1 items-center">
 | 
			
		||||
							<h2 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
 | 
			
		||||
						<div className="relative flex items-center">
 | 
			
		||||
							<h2 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100 whitespace-nowrap">
 | 
			
		||||
								{getPageTitle()}
 | 
			
		||||
							</h2>
 | 
			
		||||
						</div>
 | 
			
		||||
						<div className="flex items-center gap-x-4 lg:gap-x-6">
 | 
			
		||||
 | 
			
		||||
						{/* Global Search Bar */}
 | 
			
		||||
						<div className="hidden md:flex items-center max-w-sm">
 | 
			
		||||
							<GlobalSearch />
 | 
			
		||||
						</div>
 | 
			
		||||
 | 
			
		||||
						<div className="flex flex-1 items-center gap-x-4 lg:gap-x-6 justify-end">
 | 
			
		||||
							{/* External Links */}
 | 
			
		||||
							<div className="flex items-center gap-2">
 | 
			
		||||
								<a
 | 
			
		||||
									href="https://github.com/9technologygroup/patchmon.net"
 | 
			
		||||
									href="https://github.com/PatchMon/PatchMon"
 | 
			
		||||
									target="_blank"
 | 
			
		||||
									rel="noopener noreferrer"
 | 
			
		||||
									className="flex items-center justify-center gap-1.5 px-3 py-2 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm group relative"
 | 
			
		||||
@@ -888,6 +896,15 @@ const Layout = ({ children }) => {
 | 
			
		||||
										</div>
 | 
			
		||||
									)}
 | 
			
		||||
								</a>
 | 
			
		||||
								<a
 | 
			
		||||
									href="https://github.com/orgs/PatchMon/projects/2/views/1"
 | 
			
		||||
									target="_blank"
 | 
			
		||||
									rel="noopener noreferrer"
 | 
			
		||||
									className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
 | 
			
		||||
									title="Roadmap"
 | 
			
		||||
								>
 | 
			
		||||
									<Route className="h-5 w-5" />
 | 
			
		||||
								</a>
 | 
			
		||||
								<a
 | 
			
		||||
									href="https://patchmon.net/discord"
 | 
			
		||||
									target="_blank"
 | 
			
		||||
@@ -895,7 +912,16 @@ const Layout = ({ children }) => {
 | 
			
		||||
									className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
 | 
			
		||||
									title="Discord"
 | 
			
		||||
								>
 | 
			
		||||
									<MessageCircle className="h-5 w-5" />
 | 
			
		||||
									<DiscordIcon className="h-5 w-5" />
 | 
			
		||||
								</a>
 | 
			
		||||
								<a
 | 
			
		||||
									href="https://docs.patchmon.net"
 | 
			
		||||
									target="_blank"
 | 
			
		||||
									rel="noopener noreferrer"
 | 
			
		||||
									className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
 | 
			
		||||
									title="Documentation"
 | 
			
		||||
								>
 | 
			
		||||
									<BookOpen className="h-5 w-5" />
 | 
			
		||||
								</a>
 | 
			
		||||
								<a
 | 
			
		||||
									href="mailto:support@patchmon.net"
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										44
									
								
								frontend/src/components/Logo.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								frontend/src/components/Logo.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,44 @@
 | 
			
		||||
import { useQuery } from "@tanstack/react-query";
 | 
			
		||||
import { useTheme } from "../contexts/ThemeContext";
 | 
			
		||||
import { settingsAPI } from "../utils/api";
 | 
			
		||||
 | 
			
		||||
const Logo = ({
 | 
			
		||||
	className = "h-8 w-auto",
 | 
			
		||||
	alt = "PatchMon Logo",
 | 
			
		||||
	...props
 | 
			
		||||
}) => {
 | 
			
		||||
	const { isDark } = useTheme();
 | 
			
		||||
 | 
			
		||||
	const { data: settings } = useQuery({
 | 
			
		||||
		queryKey: ["settings"],
 | 
			
		||||
		queryFn: () => settingsAPI.get().then((res) => res.data),
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Determine which logo to use based on theme
 | 
			
		||||
	const logoSrc = isDark
 | 
			
		||||
		? settings?.logo_dark || "/assets/logo_dark.png"
 | 
			
		||||
		: settings?.logo_light || "/assets/logo_light.png";
 | 
			
		||||
 | 
			
		||||
	// Add cache-busting parameter using updated_at timestamp
 | 
			
		||||
	const cacheBuster = settings?.updated_at
 | 
			
		||||
		? new Date(settings.updated_at).getTime()
 | 
			
		||||
		: Date.now();
 | 
			
		||||
	const logoSrcWithCache = `${logoSrc}?v=${cacheBuster}`;
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<img
 | 
			
		||||
			src={logoSrcWithCache}
 | 
			
		||||
			alt={alt}
 | 
			
		||||
			className={className}
 | 
			
		||||
			onError={(e) => {
 | 
			
		||||
				// Fallback to default logo if custom logo fails to load
 | 
			
		||||
				e.target.src = isDark
 | 
			
		||||
					? "/assets/logo_dark.png"
 | 
			
		||||
					: "/assets/logo_light.png";
 | 
			
		||||
			}}
 | 
			
		||||
			{...props}
 | 
			
		||||
		/>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default Logo;
 | 
			
		||||
							
								
								
									
										37
									
								
								frontend/src/components/LogoProvider.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								frontend/src/components/LogoProvider.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
import { useQuery } from "@tanstack/react-query";
 | 
			
		||||
import { useEffect } from "react";
 | 
			
		||||
import { settingsAPI } from "../utils/api";
 | 
			
		||||
 | 
			
		||||
const LogoProvider = ({ children }) => {
 | 
			
		||||
	const { data: settings } = useQuery({
 | 
			
		||||
		queryKey: ["settings"],
 | 
			
		||||
		queryFn: () => settingsAPI.get().then((res) => res.data),
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	useEffect(() => {
 | 
			
		||||
		// Use custom favicon or fallback to default
 | 
			
		||||
		const faviconUrl = settings?.favicon || "/assets/favicon.svg";
 | 
			
		||||
 | 
			
		||||
		// Add cache-busting parameter using updated_at timestamp
 | 
			
		||||
		const cacheBuster = settings?.updated_at
 | 
			
		||||
			? new Date(settings.updated_at).getTime()
 | 
			
		||||
			: Date.now();
 | 
			
		||||
		const faviconUrlWithCache = `${faviconUrl}?v=${cacheBuster}`;
 | 
			
		||||
 | 
			
		||||
		// Update favicon
 | 
			
		||||
		const favicon = document.querySelector('link[rel="icon"]');
 | 
			
		||||
		if (favicon) {
 | 
			
		||||
			favicon.href = faviconUrlWithCache;
 | 
			
		||||
		} else {
 | 
			
		||||
			// Create favicon link if it doesn't exist
 | 
			
		||||
			const link = document.createElement("link");
 | 
			
		||||
			link.rel = "icon";
 | 
			
		||||
			link.href = faviconUrlWithCache;
 | 
			
		||||
			document.head.appendChild(link);
 | 
			
		||||
		}
 | 
			
		||||
	}, [settings?.favicon, settings?.updated_at]);
 | 
			
		||||
 | 
			
		||||
	return children;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default LogoProvider;
 | 
			
		||||
@@ -4,6 +4,7 @@ import {
 | 
			
		||||
	ChevronRight,
 | 
			
		||||
	Code,
 | 
			
		||||
	Folder,
 | 
			
		||||
	Image,
 | 
			
		||||
	RefreshCw,
 | 
			
		||||
	Settings,
 | 
			
		||||
	Shield,
 | 
			
		||||
@@ -130,6 +131,11 @@ const SettingsLayout = ({ children }) => {
 | 
			
		||||
						href: "/settings/server-url",
 | 
			
		||||
						icon: Wrench,
 | 
			
		||||
					},
 | 
			
		||||
					{
 | 
			
		||||
						name: "Branding",
 | 
			
		||||
						href: "/settings/branding",
 | 
			
		||||
						icon: Image,
 | 
			
		||||
					},
 | 
			
		||||
					{
 | 
			
		||||
						name: "Server Version",
 | 
			
		||||
						href: "/settings/server-version",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										531
									
								
								frontend/src/components/settings/BrandingTab.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										531
									
								
								frontend/src/components/settings/BrandingTab.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,531 @@
 | 
			
		||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 | 
			
		||||
import { AlertCircle, Image, RotateCcw, Upload, X } from "lucide-react";
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import { settingsAPI } from "../../utils/api";
 | 
			
		||||
 | 
			
		||||
const BrandingTab = () => {
 | 
			
		||||
	// Logo management state
 | 
			
		||||
	const [logoUploadState, setLogoUploadState] = useState({
 | 
			
		||||
		dark: { uploading: false, error: null },
 | 
			
		||||
		light: { uploading: false, error: null },
 | 
			
		||||
		favicon: { uploading: false, error: null },
 | 
			
		||||
	});
 | 
			
		||||
	const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
 | 
			
		||||
	const [selectedLogoType, setSelectedLogoType] = useState("dark");
 | 
			
		||||
 | 
			
		||||
	const queryClient = useQueryClient();
 | 
			
		||||
 | 
			
		||||
	// Fetch current settings
 | 
			
		||||
	const {
 | 
			
		||||
		data: settings,
 | 
			
		||||
		isLoading,
 | 
			
		||||
		error,
 | 
			
		||||
	} = useQuery({
 | 
			
		||||
		queryKey: ["settings"],
 | 
			
		||||
		queryFn: () => settingsAPI.get().then((res) => res.data),
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Logo upload mutation
 | 
			
		||||
	const uploadLogoMutation = useMutation({
 | 
			
		||||
		mutationFn: ({ logoType, fileContent, fileName }) =>
 | 
			
		||||
			fetch("/api/v1/settings/logos/upload", {
 | 
			
		||||
				method: "POST",
 | 
			
		||||
				headers: {
 | 
			
		||||
					"Content-Type": "application/json",
 | 
			
		||||
					Authorization: `Bearer ${localStorage.getItem("token")}`,
 | 
			
		||||
				},
 | 
			
		||||
				body: JSON.stringify({ logoType, fileContent, fileName }),
 | 
			
		||||
			}).then((res) => res.json()),
 | 
			
		||||
		onSuccess: (_data, variables) => {
 | 
			
		||||
			queryClient.invalidateQueries(["settings"]);
 | 
			
		||||
			setLogoUploadState((prev) => ({
 | 
			
		||||
				...prev,
 | 
			
		||||
				[variables.logoType]: { uploading: false, error: null },
 | 
			
		||||
			}));
 | 
			
		||||
			setShowLogoUploadModal(false);
 | 
			
		||||
		},
 | 
			
		||||
		onError: (error, variables) => {
 | 
			
		||||
			console.error("Upload logo error:", error);
 | 
			
		||||
			setLogoUploadState((prev) => ({
 | 
			
		||||
				...prev,
 | 
			
		||||
				[variables.logoType]: {
 | 
			
		||||
					uploading: false,
 | 
			
		||||
					error: error.message || "Failed to upload logo",
 | 
			
		||||
				},
 | 
			
		||||
			}));
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Logo reset mutation
 | 
			
		||||
	const resetLogoMutation = useMutation({
 | 
			
		||||
		mutationFn: (logoType) =>
 | 
			
		||||
			fetch("/api/v1/settings/logos/reset", {
 | 
			
		||||
				method: "POST",
 | 
			
		||||
				headers: {
 | 
			
		||||
					"Content-Type": "application/json",
 | 
			
		||||
					Authorization: `Bearer ${localStorage.getItem("token")}`,
 | 
			
		||||
				},
 | 
			
		||||
				body: JSON.stringify({ logoType }),
 | 
			
		||||
			}).then((res) => res.json()),
 | 
			
		||||
		onSuccess: () => {
 | 
			
		||||
			queryClient.invalidateQueries(["settings"]);
 | 
			
		||||
		},
 | 
			
		||||
		onError: (error) => {
 | 
			
		||||
			console.error("Reset logo error:", error);
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	if (isLoading) {
 | 
			
		||||
		return (
 | 
			
		||||
			<div className="flex items-center justify-center h-64">
 | 
			
		||||
				<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
 | 
			
		||||
			</div>
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (error) {
 | 
			
		||||
		return (
 | 
			
		||||
			<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
 | 
			
		||||
				<div className="flex">
 | 
			
		||||
					<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
 | 
			
		||||
					<div className="ml-3">
 | 
			
		||||
						<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
 | 
			
		||||
							Error loading settings
 | 
			
		||||
						</h3>
 | 
			
		||||
						<p className="mt-1 text-sm text-red-700 dark:text-red-300">
 | 
			
		||||
							{error.response?.data?.error || "Failed to load settings"}
 | 
			
		||||
						</p>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="space-y-6">
 | 
			
		||||
			<div className="flex items-center mb-6">
 | 
			
		||||
				<Image className="h-6 w-6 text-primary-600 mr-3" />
 | 
			
		||||
				<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
			
		||||
					Logo & Branding
 | 
			
		||||
				</h2>
 | 
			
		||||
			</div>
 | 
			
		||||
			<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
 | 
			
		||||
				Customize your PatchMon installation with custom logos and favicon.
 | 
			
		||||
				These will be displayed throughout the application.
 | 
			
		||||
			</p>
 | 
			
		||||
 | 
			
		||||
			<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
 | 
			
		||||
				{/* Dark Logo */}
 | 
			
		||||
				<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
					<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-4">
 | 
			
		||||
						Dark Logo
 | 
			
		||||
					</h4>
 | 
			
		||||
					<div className="flex items-center justify-center p-4 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-4">
 | 
			
		||||
						<img
 | 
			
		||||
							src={`${settings?.logo_dark || "/assets/logo_dark.png"}?v=${Date.now()}`}
 | 
			
		||||
							alt="Dark Logo"
 | 
			
		||||
							className="max-h-16 max-w-full object-contain"
 | 
			
		||||
							onError={(e) => {
 | 
			
		||||
								e.target.src = "/assets/logo_dark.png";
 | 
			
		||||
							}}
 | 
			
		||||
						/>
 | 
			
		||||
					</div>
 | 
			
		||||
					<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-4 truncate">
 | 
			
		||||
						{settings?.logo_dark
 | 
			
		||||
							? settings.logo_dark.split("/").pop()
 | 
			
		||||
							: "logo_dark.png (Default)"}
 | 
			
		||||
					</p>
 | 
			
		||||
					<div className="space-y-2">
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
							onClick={() => {
 | 
			
		||||
								setSelectedLogoType("dark");
 | 
			
		||||
								setShowLogoUploadModal(true);
 | 
			
		||||
							}}
 | 
			
		||||
							disabled={logoUploadState.dark.uploading}
 | 
			
		||||
							className="w-full btn-outline flex items-center justify-center gap-2"
 | 
			
		||||
						>
 | 
			
		||||
							{logoUploadState.dark.uploading ? (
 | 
			
		||||
								<>
 | 
			
		||||
									<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
 | 
			
		||||
									Uploading...
 | 
			
		||||
								</>
 | 
			
		||||
							) : (
 | 
			
		||||
								<>
 | 
			
		||||
									<Upload className="h-4 w-4" />
 | 
			
		||||
									Upload Dark Logo
 | 
			
		||||
								</>
 | 
			
		||||
							)}
 | 
			
		||||
						</button>
 | 
			
		||||
						{settings?.logo_dark && (
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={() => resetLogoMutation.mutate("dark")}
 | 
			
		||||
								disabled={resetLogoMutation.isPending}
 | 
			
		||||
								className="w-full btn-outline flex items-center justify-center gap-2 text-orange-600 hover:text-orange-700 border-orange-300 hover:border-orange-400"
 | 
			
		||||
							>
 | 
			
		||||
								<RotateCcw className="h-4 w-4" />
 | 
			
		||||
								Reset to Default
 | 
			
		||||
							</button>
 | 
			
		||||
						)}
 | 
			
		||||
					</div>
 | 
			
		||||
					{logoUploadState.dark.error && (
 | 
			
		||||
						<p className="text-xs text-red-600 dark:text-red-400 mt-2">
 | 
			
		||||
							{logoUploadState.dark.error}
 | 
			
		||||
						</p>
 | 
			
		||||
					)}
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				{/* Light Logo */}
 | 
			
		||||
				<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
					<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-4">
 | 
			
		||||
						Light Logo
 | 
			
		||||
					</h4>
 | 
			
		||||
					<div className="flex items-center justify-center p-4 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-4">
 | 
			
		||||
						<img
 | 
			
		||||
							src={`${settings?.logo_light || "/assets/logo_light.png"}?v=${Date.now()}`}
 | 
			
		||||
							alt="Light Logo"
 | 
			
		||||
							className="max-h-16 max-w-full object-contain"
 | 
			
		||||
							onError={(e) => {
 | 
			
		||||
								e.target.src = "/assets/logo_light.png";
 | 
			
		||||
							}}
 | 
			
		||||
						/>
 | 
			
		||||
					</div>
 | 
			
		||||
					<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-4 truncate">
 | 
			
		||||
						{settings?.logo_light
 | 
			
		||||
							? settings.logo_light.split("/").pop()
 | 
			
		||||
							: "logo_light.png (Default)"}
 | 
			
		||||
					</p>
 | 
			
		||||
					<div className="space-y-2">
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
							onClick={() => {
 | 
			
		||||
								setSelectedLogoType("light");
 | 
			
		||||
								setShowLogoUploadModal(true);
 | 
			
		||||
							}}
 | 
			
		||||
							disabled={logoUploadState.light.uploading}
 | 
			
		||||
							className="w-full btn-outline flex items-center justify-center gap-2"
 | 
			
		||||
						>
 | 
			
		||||
							{logoUploadState.light.uploading ? (
 | 
			
		||||
								<>
 | 
			
		||||
									<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
 | 
			
		||||
									Uploading...
 | 
			
		||||
								</>
 | 
			
		||||
							) : (
 | 
			
		||||
								<>
 | 
			
		||||
									<Upload className="h-4 w-4" />
 | 
			
		||||
									Upload Light Logo
 | 
			
		||||
								</>
 | 
			
		||||
							)}
 | 
			
		||||
						</button>
 | 
			
		||||
						{settings?.logo_light && (
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={() => resetLogoMutation.mutate("light")}
 | 
			
		||||
								disabled={resetLogoMutation.isPending}
 | 
			
		||||
								className="w-full btn-outline flex items-center justify-center gap-2 text-orange-600 hover:text-orange-700 border-orange-300 hover:border-orange-400"
 | 
			
		||||
							>
 | 
			
		||||
								<RotateCcw className="h-4 w-4" />
 | 
			
		||||
								Reset to Default
 | 
			
		||||
							</button>
 | 
			
		||||
						)}
 | 
			
		||||
					</div>
 | 
			
		||||
					{logoUploadState.light.error && (
 | 
			
		||||
						<p className="text-xs text-red-600 dark:text-red-400 mt-2">
 | 
			
		||||
							{logoUploadState.light.error}
 | 
			
		||||
						</p>
 | 
			
		||||
					)}
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				{/* Favicon */}
 | 
			
		||||
				<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
					<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-4">
 | 
			
		||||
						Favicon
 | 
			
		||||
					</h4>
 | 
			
		||||
					<div className="flex items-center justify-center p-4 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-4">
 | 
			
		||||
						<img
 | 
			
		||||
							src={`${settings?.favicon || "/assets/favicon.svg"}?v=${Date.now()}`}
 | 
			
		||||
							alt="Favicon"
 | 
			
		||||
							className="h-8 w-8 object-contain"
 | 
			
		||||
							onError={(e) => {
 | 
			
		||||
								e.target.src = "/assets/favicon.svg";
 | 
			
		||||
							}}
 | 
			
		||||
						/>
 | 
			
		||||
					</div>
 | 
			
		||||
					<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-4 truncate">
 | 
			
		||||
						{settings?.favicon
 | 
			
		||||
							? settings.favicon.split("/").pop()
 | 
			
		||||
							: "favicon.svg (Default)"}
 | 
			
		||||
					</p>
 | 
			
		||||
					<div className="space-y-2">
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
							onClick={() => {
 | 
			
		||||
								setSelectedLogoType("favicon");
 | 
			
		||||
								setShowLogoUploadModal(true);
 | 
			
		||||
							}}
 | 
			
		||||
							disabled={logoUploadState.favicon.uploading}
 | 
			
		||||
							className="w-full btn-outline flex items-center justify-center gap-2"
 | 
			
		||||
						>
 | 
			
		||||
							{logoUploadState.favicon.uploading ? (
 | 
			
		||||
								<>
 | 
			
		||||
									<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
 | 
			
		||||
									Uploading...
 | 
			
		||||
								</>
 | 
			
		||||
							) : (
 | 
			
		||||
								<>
 | 
			
		||||
									<Upload className="h-4 w-4" />
 | 
			
		||||
									Upload Favicon
 | 
			
		||||
								</>
 | 
			
		||||
							)}
 | 
			
		||||
						</button>
 | 
			
		||||
						{settings?.favicon && (
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={() => resetLogoMutation.mutate("favicon")}
 | 
			
		||||
								disabled={resetLogoMutation.isPending}
 | 
			
		||||
								className="w-full btn-outline flex items-center justify-center gap-2 text-orange-600 hover:text-orange-700 border-orange-300 hover:border-orange-400"
 | 
			
		||||
							>
 | 
			
		||||
								<RotateCcw className="h-4 w-4" />
 | 
			
		||||
								Reset to Default
 | 
			
		||||
							</button>
 | 
			
		||||
						)}
 | 
			
		||||
					</div>
 | 
			
		||||
					{logoUploadState.favicon.error && (
 | 
			
		||||
						<p className="text-xs text-red-600 dark:text-red-400 mt-2">
 | 
			
		||||
							{logoUploadState.favicon.error}
 | 
			
		||||
						</p>
 | 
			
		||||
					)}
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			{/* Usage Instructions */}
 | 
			
		||||
			<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-md p-4 mt-6">
 | 
			
		||||
				<div className="flex">
 | 
			
		||||
					<Image className="h-5 w-5 text-blue-400 dark:text-blue-300" />
 | 
			
		||||
					<div className="ml-3">
 | 
			
		||||
						<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200">
 | 
			
		||||
							Logo Usage
 | 
			
		||||
						</h3>
 | 
			
		||||
						<div className="mt-2 text-sm text-blue-700 dark:text-blue-300">
 | 
			
		||||
							<p className="mb-2">
 | 
			
		||||
								These logos are used throughout the application:
 | 
			
		||||
							</p>
 | 
			
		||||
							<ul className="list-disc list-inside space-y-1">
 | 
			
		||||
								<li>
 | 
			
		||||
									<strong>Dark Logo:</strong> Used in dark mode and on light
 | 
			
		||||
									backgrounds
 | 
			
		||||
								</li>
 | 
			
		||||
								<li>
 | 
			
		||||
									<strong>Light Logo:</strong> Used in light mode and on dark
 | 
			
		||||
									backgrounds
 | 
			
		||||
								</li>
 | 
			
		||||
								<li>
 | 
			
		||||
									<strong>Favicon:</strong> Used as the browser tab icon (SVG
 | 
			
		||||
									recommended)
 | 
			
		||||
								</li>
 | 
			
		||||
							</ul>
 | 
			
		||||
							<p className="mt-3 text-xs">
 | 
			
		||||
								<strong>Supported formats:</strong> PNG, JPG, SVG |{" "}
 | 
			
		||||
								<strong>Max size:</strong> 5MB |{" "}
 | 
			
		||||
								<strong>Recommended sizes:</strong> 200x60px for logos, 32x32px
 | 
			
		||||
								for favicon.
 | 
			
		||||
							</p>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			{/* Logo Upload Modal */}
 | 
			
		||||
			{showLogoUploadModal && (
 | 
			
		||||
				<LogoUploadModal
 | 
			
		||||
					isOpen={showLogoUploadModal}
 | 
			
		||||
					onClose={() => setShowLogoUploadModal(false)}
 | 
			
		||||
					onSubmit={uploadLogoMutation.mutate}
 | 
			
		||||
					isLoading={uploadLogoMutation.isPending}
 | 
			
		||||
					error={uploadLogoMutation.error}
 | 
			
		||||
					logoType={selectedLogoType}
 | 
			
		||||
				/>
 | 
			
		||||
			)}
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Logo Upload Modal Component
 | 
			
		||||
const LogoUploadModal = ({
 | 
			
		||||
	isOpen,
 | 
			
		||||
	onClose,
 | 
			
		||||
	onSubmit,
 | 
			
		||||
	isLoading,
 | 
			
		||||
	error,
 | 
			
		||||
	logoType,
 | 
			
		||||
}) => {
 | 
			
		||||
	const [selectedFile, setSelectedFile] = useState(null);
 | 
			
		||||
	const [previewUrl, setPreviewUrl] = useState(null);
 | 
			
		||||
	const [uploadError, setUploadError] = useState("");
 | 
			
		||||
 | 
			
		||||
	const handleFileSelect = (e) => {
 | 
			
		||||
		const file = e.target.files[0];
 | 
			
		||||
		if (file) {
 | 
			
		||||
			// Validate file type
 | 
			
		||||
			const allowedTypes = [
 | 
			
		||||
				"image/png",
 | 
			
		||||
				"image/jpeg",
 | 
			
		||||
				"image/jpg",
 | 
			
		||||
				"image/svg+xml",
 | 
			
		||||
			];
 | 
			
		||||
			if (!allowedTypes.includes(file.type)) {
 | 
			
		||||
				setUploadError("Please select a PNG, JPG, or SVG file");
 | 
			
		||||
				return;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Validate file size (5MB limit)
 | 
			
		||||
			if (file.size > 5 * 1024 * 1024) {
 | 
			
		||||
				setUploadError("File size must be less than 5MB");
 | 
			
		||||
				return;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			setSelectedFile(file);
 | 
			
		||||
			setUploadError("");
 | 
			
		||||
 | 
			
		||||
			// Create preview URL
 | 
			
		||||
			const url = URL.createObjectURL(file);
 | 
			
		||||
			setPreviewUrl(url);
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const handleSubmit = (e) => {
 | 
			
		||||
		e.preventDefault();
 | 
			
		||||
		setUploadError("");
 | 
			
		||||
 | 
			
		||||
		if (!selectedFile) {
 | 
			
		||||
			setUploadError("Please select a file");
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Convert file to base64
 | 
			
		||||
		const reader = new FileReader();
 | 
			
		||||
		reader.onload = (event) => {
 | 
			
		||||
			const base64 = event.target.result;
 | 
			
		||||
			onSubmit({
 | 
			
		||||
				logoType,
 | 
			
		||||
				fileContent: base64,
 | 
			
		||||
				fileName: selectedFile.name,
 | 
			
		||||
			});
 | 
			
		||||
		};
 | 
			
		||||
		reader.readAsDataURL(selectedFile);
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const handleClose = () => {
 | 
			
		||||
		setSelectedFile(null);
 | 
			
		||||
		setPreviewUrl(null);
 | 
			
		||||
		setUploadError("");
 | 
			
		||||
		onClose();
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	if (!isOpen) return null;
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
 | 
			
		||||
			<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
 | 
			
		||||
				<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
					<div className="flex items-center justify-between">
 | 
			
		||||
						<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
 | 
			
		||||
							Upload{" "}
 | 
			
		||||
							{logoType === "favicon"
 | 
			
		||||
								? "Favicon"
 | 
			
		||||
								: `${logoType.charAt(0).toUpperCase() + logoType.slice(1)} Logo`}
 | 
			
		||||
						</h3>
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
							onClick={handleClose}
 | 
			
		||||
							className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
 | 
			
		||||
						>
 | 
			
		||||
							<X className="h-5 w-5" />
 | 
			
		||||
						</button>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				<form onSubmit={handleSubmit} className="px-6 py-4">
 | 
			
		||||
					<div className="space-y-4">
 | 
			
		||||
						<div>
 | 
			
		||||
							<label className="block">
 | 
			
		||||
								<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
 | 
			
		||||
									Select File
 | 
			
		||||
								</span>
 | 
			
		||||
								<input
 | 
			
		||||
									type="file"
 | 
			
		||||
									accept="image/png,image/jpeg,image/jpg,image/svg+xml"
 | 
			
		||||
									onChange={handleFileSelect}
 | 
			
		||||
									className="block w-full text-sm text-secondary-500 dark:text-secondary-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900 dark:file:text-primary-200"
 | 
			
		||||
								/>
 | 
			
		||||
							</label>
 | 
			
		||||
							<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
 | 
			
		||||
								Supported formats: PNG, JPG, SVG. Max size: 5MB.
 | 
			
		||||
								{logoType === "favicon"
 | 
			
		||||
									? " Recommended: 32x32px SVG."
 | 
			
		||||
									: " Recommended: 200x60px."}
 | 
			
		||||
							</p>
 | 
			
		||||
						</div>
 | 
			
		||||
 | 
			
		||||
						{previewUrl && (
 | 
			
		||||
							<div>
 | 
			
		||||
								<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
 | 
			
		||||
									Preview
 | 
			
		||||
								</div>
 | 
			
		||||
								<div className="flex items-center justify-center p-4 bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
									<img
 | 
			
		||||
										src={previewUrl}
 | 
			
		||||
										alt="Preview"
 | 
			
		||||
										className={`object-contain ${
 | 
			
		||||
											logoType === "favicon" ? "h-8 w-8" : "max-h-16 max-w-full"
 | 
			
		||||
										}`}
 | 
			
		||||
									/>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						)}
 | 
			
		||||
 | 
			
		||||
						{(uploadError || error) && (
 | 
			
		||||
							<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
 | 
			
		||||
								<p className="text-sm text-red-800 dark:text-red-200">
 | 
			
		||||
									{uploadError ||
 | 
			
		||||
										error?.response?.data?.error ||
 | 
			
		||||
										error?.message}
 | 
			
		||||
								</p>
 | 
			
		||||
							</div>
 | 
			
		||||
						)}
 | 
			
		||||
 | 
			
		||||
						<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3">
 | 
			
		||||
							<div className="flex">
 | 
			
		||||
								<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mr-2 mt-0.5" />
 | 
			
		||||
								<div className="text-sm text-yellow-800 dark:text-yellow-200">
 | 
			
		||||
									<p className="font-medium">Important:</p>
 | 
			
		||||
									<ul className="mt-1 list-disc list-inside space-y-1">
 | 
			
		||||
										<li>This will replace the current {logoType} logo</li>
 | 
			
		||||
										<li>A backup will be created automatically</li>
 | 
			
		||||
										<li>The change will be applied immediately</li>
 | 
			
		||||
									</ul>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
 | 
			
		||||
					<div className="flex justify-end gap-3 mt-6">
 | 
			
		||||
						<button type="button" onClick={handleClose} className="btn-outline">
 | 
			
		||||
							Cancel
 | 
			
		||||
						</button>
 | 
			
		||||
						<button
 | 
			
		||||
							type="submit"
 | 
			
		||||
							disabled={isLoading || !selectedFile}
 | 
			
		||||
							className="btn-primary"
 | 
			
		||||
						>
 | 
			
		||||
							{isLoading ? "Uploading..." : "Upload Logo"}
 | 
			
		||||
						</button>
 | 
			
		||||
					</div>
 | 
			
		||||
				</form>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default BrandingTab;
 | 
			
		||||
@@ -92,7 +92,12 @@ const UsersTab = () => {
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const handleEditUser = (user) => {
 | 
			
		||||
		setEditingUser(user);
 | 
			
		||||
		// Reset editingUser first to force re-render with fresh data
 | 
			
		||||
		setEditingUser(null);
 | 
			
		||||
		// Use setTimeout to ensure the modal re-initializes with fresh data
 | 
			
		||||
		setTimeout(() => {
 | 
			
		||||
			setEditingUser(user);
 | 
			
		||||
		}, 0);
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const handleResetPassword = (user) => {
 | 
			
		||||
@@ -314,7 +319,9 @@ const UsersTab = () => {
 | 
			
		||||
					user={editingUser}
 | 
			
		||||
					isOpen={!!editingUser}
 | 
			
		||||
					onClose={() => setEditingUser(null)}
 | 
			
		||||
					onUserUpdated={() => updateUserMutation.mutate()}
 | 
			
		||||
					onUserUpdated={() => {
 | 
			
		||||
						queryClient.invalidateQueries(["users"]);
 | 
			
		||||
					}}
 | 
			
		||||
					roles={roles}
 | 
			
		||||
				/>
 | 
			
		||||
			)}
 | 
			
		||||
@@ -352,11 +359,29 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
 | 
			
		||||
	});
 | 
			
		||||
	const [isLoading, setIsLoading] = useState(false);
 | 
			
		||||
	const [error, setError] = useState("");
 | 
			
		||||
	const [success, setSuccess] = useState(false);
 | 
			
		||||
 | 
			
		||||
	// Reset form when modal is closed
 | 
			
		||||
	useEffect(() => {
 | 
			
		||||
		if (!isOpen) {
 | 
			
		||||
			setFormData({
 | 
			
		||||
				username: "",
 | 
			
		||||
				email: "",
 | 
			
		||||
				password: "",
 | 
			
		||||
				first_name: "",
 | 
			
		||||
				last_name: "",
 | 
			
		||||
				role: "user",
 | 
			
		||||
			});
 | 
			
		||||
			setError("");
 | 
			
		||||
			setSuccess(false);
 | 
			
		||||
		}
 | 
			
		||||
	}, [isOpen]);
 | 
			
		||||
 | 
			
		||||
	const handleSubmit = async (e) => {
 | 
			
		||||
		e.preventDefault();
 | 
			
		||||
		setIsLoading(true);
 | 
			
		||||
		setError("");
 | 
			
		||||
		setSuccess(false);
 | 
			
		||||
 | 
			
		||||
		try {
 | 
			
		||||
			// Only send role if roles are available from API
 | 
			
		||||
@@ -364,12 +389,19 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
 | 
			
		||||
				username: formData.username,
 | 
			
		||||
				email: formData.email,
 | 
			
		||||
				password: formData.password,
 | 
			
		||||
				first_name: formData.first_name,
 | 
			
		||||
				last_name: formData.last_name,
 | 
			
		||||
			};
 | 
			
		||||
			if (roles && Array.isArray(roles) && roles.length > 0) {
 | 
			
		||||
				payload.role = formData.role;
 | 
			
		||||
			}
 | 
			
		||||
			await adminUsersAPI.create(payload);
 | 
			
		||||
			setSuccess(true);
 | 
			
		||||
			onUserCreated();
 | 
			
		||||
			// Auto-close after 1.5 seconds
 | 
			
		||||
			setTimeout(() => {
 | 
			
		||||
				onClose();
 | 
			
		||||
			}, 1500);
 | 
			
		||||
		} catch (err) {
 | 
			
		||||
			setError(err.response?.data?.error || "Failed to create user");
 | 
			
		||||
		} finally {
 | 
			
		||||
@@ -517,6 +549,17 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
 | 
			
		||||
						</select>
 | 
			
		||||
					</div>
 | 
			
		||||
 | 
			
		||||
					{success && (
 | 
			
		||||
						<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-3">
 | 
			
		||||
							<div className="flex items-center">
 | 
			
		||||
								<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400 mr-2" />
 | 
			
		||||
								<p className="text-sm text-green-700 dark:text-green-300">
 | 
			
		||||
									User created successfully!
 | 
			
		||||
								</p>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					)}
 | 
			
		||||
 | 
			
		||||
					{error && (
 | 
			
		||||
						<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
 | 
			
		||||
							<p className="text-sm text-danger-700 dark:text-danger-300">
 | 
			
		||||
@@ -566,15 +609,44 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
 | 
			
		||||
	});
 | 
			
		||||
	const [isLoading, setIsLoading] = useState(false);
 | 
			
		||||
	const [error, setError] = useState("");
 | 
			
		||||
	const [success, setSuccess] = useState(false);
 | 
			
		||||
 | 
			
		||||
	// Update formData when user prop changes or modal opens
 | 
			
		||||
	useEffect(() => {
 | 
			
		||||
		if (user && isOpen) {
 | 
			
		||||
			setFormData({
 | 
			
		||||
				username: user.username || "",
 | 
			
		||||
				email: user.email || "",
 | 
			
		||||
				first_name: user.first_name || "",
 | 
			
		||||
				last_name: user.last_name || "",
 | 
			
		||||
				role: user.role || "user",
 | 
			
		||||
				is_active: user.is_active ?? true,
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
	}, [user, isOpen]);
 | 
			
		||||
 | 
			
		||||
	// Reset error and success when modal closes
 | 
			
		||||
	useEffect(() => {
 | 
			
		||||
		if (!isOpen) {
 | 
			
		||||
			setError("");
 | 
			
		||||
			setSuccess(false);
 | 
			
		||||
		}
 | 
			
		||||
	}, [isOpen]);
 | 
			
		||||
 | 
			
		||||
	const handleSubmit = async (e) => {
 | 
			
		||||
		e.preventDefault();
 | 
			
		||||
		setIsLoading(true);
 | 
			
		||||
		setError("");
 | 
			
		||||
		setSuccess(false);
 | 
			
		||||
 | 
			
		||||
		try {
 | 
			
		||||
			await adminUsersAPI.update(user.id, formData);
 | 
			
		||||
			setSuccess(true);
 | 
			
		||||
			onUserUpdated();
 | 
			
		||||
			// Auto-close after 1.5 seconds
 | 
			
		||||
			setTimeout(() => {
 | 
			
		||||
				onClose();
 | 
			
		||||
			}, 1500);
 | 
			
		||||
		} catch (err) {
 | 
			
		||||
			setError(err.response?.data?.error || "Failed to update user");
 | 
			
		||||
		} finally {
 | 
			
		||||
@@ -718,6 +790,17 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
 | 
			
		||||
						</label>
 | 
			
		||||
					</div>
 | 
			
		||||
 | 
			
		||||
					{success && (
 | 
			
		||||
						<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-3">
 | 
			
		||||
							<div className="flex items-center">
 | 
			
		||||
								<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400 mr-2" />
 | 
			
		||||
								<p className="text-sm text-green-700 dark:text-green-300">
 | 
			
		||||
									User updated successfully!
 | 
			
		||||
								</p>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					)}
 | 
			
		||||
 | 
			
		||||
					{error && (
 | 
			
		||||
						<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
 | 
			
		||||
							<p className="text-sm text-danger-700 dark:text-danger-300">
 | 
			
		||||
 
 | 
			
		||||
@@ -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,274 @@ 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 and automatically check for updates on component mount
 | 
			
		||||
	useEffect(() => {
 | 
			
		||||
		const loadAndCheckUpdates = async () => {
 | 
			
		||||
			try {
 | 
			
		||||
				// First, get current version info
 | 
			
		||||
				const response = await versionAPI.getCurrent();
 | 
			
		||||
				const data = response.data;
 | 
			
		||||
				setVersionInfo({
 | 
			
		||||
					currentVersion: data.version,
 | 
			
		||||
					latestVersion: data.latest_version || null,
 | 
			
		||||
					isUpdateAvailable: data.is_update_available || false,
 | 
			
		||||
					last_update_check: data.last_update_check || null,
 | 
			
		||||
					github: data.github,
 | 
			
		||||
					checking: false,
 | 
			
		||||
					error: null,
 | 
			
		||||
				});
 | 
			
		||||
 | 
			
		||||
		setSshTestResult({
 | 
			
		||||
			testing: true,
 | 
			
		||||
			success: null,
 | 
			
		||||
			message: null,
 | 
			
		||||
			error: null,
 | 
			
		||||
		});
 | 
			
		||||
				// Then automatically trigger a fresh update check
 | 
			
		||||
				await checkForUpdates();
 | 
			
		||||
			} catch (error) {
 | 
			
		||||
				console.error("Error loading version info:", error);
 | 
			
		||||
				setVersionInfo((prev) => ({
 | 
			
		||||
					...prev,
 | 
			
		||||
					error: "Failed to load version information",
 | 
			
		||||
				}));
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		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 (
 | 
			
		||||
			<div className="flex items-center justify-center h-64">
 | 
			
		||||
				<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
 | 
			
		||||
			</div>
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (error) {
 | 
			
		||||
		return (
 | 
			
		||||
			<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
 | 
			
		||||
				<div className="flex">
 | 
			
		||||
					<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
 | 
			
		||||
					<div className="ml-3">
 | 
			
		||||
						<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
 | 
			
		||||
							Error loading settings
 | 
			
		||||
						</h3>
 | 
			
		||||
						<p className="mt-1 text-sm text-red-700 dark:text-red-300">
 | 
			
		||||
							{error.response?.data?.error || "Failed to load settings"}
 | 
			
		||||
						</p>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
		loadAndCheckUpdates();
 | 
			
		||||
	}, [checkForUpdates]); // Run when component mounts
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="space-y-6">
 | 
			
		||||
			{errors.general && (
 | 
			
		||||
				<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
 | 
			
		||||
					<div className="flex">
 | 
			
		||||
						<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
 | 
			
		||||
						<div className="ml-3">
 | 
			
		||||
							<p className="text-sm text-red-700 dark:text-red-300">
 | 
			
		||||
								{errors.general}
 | 
			
		||||
							</p>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			)}
 | 
			
		||||
 | 
			
		||||
			<div className="flex items-center mb-6">
 | 
			
		||||
				<Code className="h-6 w-6 text-primary-600 mr-3" />
 | 
			
		||||
				<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
			
		||||
					Server Version Management
 | 
			
		||||
					Server Version Information
 | 
			
		||||
				</h2>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-6">
 | 
			
		||||
				<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
 | 
			
		||||
					Version Check Configuration
 | 
			
		||||
					Version Information
 | 
			
		||||
				</h3>
 | 
			
		||||
				<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-6">
 | 
			
		||||
					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 && (
 | 
			
		||||
						<span className="ml-2 text-blue-600 dark:text-blue-400">
 | 
			
		||||
							🔄 Checking for updates...
 | 
			
		||||
						</span>
 | 
			
		||||
					)}
 | 
			
		||||
				</p>
 | 
			
		||||
 | 
			
		||||
				<div className="space-y-4">
 | 
			
		||||
					<fieldset>
 | 
			
		||||
						<legend className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
 | 
			
		||||
							Repository Type
 | 
			
		||||
						</legend>
 | 
			
		||||
						<div className="space-y-2">
 | 
			
		||||
							<div className="flex items-center">
 | 
			
		||||
								<input
 | 
			
		||||
									type="radio"
 | 
			
		||||
									id={repoPublicId}
 | 
			
		||||
									name="repositoryType"
 | 
			
		||||
									value="public"
 | 
			
		||||
									checked={formData.repositoryType === "public"}
 | 
			
		||||
									onChange={(e) =>
 | 
			
		||||
										handleInputChange("repositoryType", e.target.value)
 | 
			
		||||
									}
 | 
			
		||||
									className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300"
 | 
			
		||||
								/>
 | 
			
		||||
								<label
 | 
			
		||||
									htmlFor={repoPublicId}
 | 
			
		||||
									className="ml-2 text-sm text-secondary-700 dark:text-secondary-200"
 | 
			
		||||
								>
 | 
			
		||||
									Public Repository (uses GitHub API - no authentication
 | 
			
		||||
									required)
 | 
			
		||||
								</label>
 | 
			
		||||
							</div>
 | 
			
		||||
							<div className="flex items-center">
 | 
			
		||||
								<input
 | 
			
		||||
									type="radio"
 | 
			
		||||
									id={repoPrivateId}
 | 
			
		||||
									name="repositoryType"
 | 
			
		||||
									value="private"
 | 
			
		||||
									checked={formData.repositoryType === "private"}
 | 
			
		||||
									onChange={(e) =>
 | 
			
		||||
										handleInputChange("repositoryType", e.target.value)
 | 
			
		||||
									}
 | 
			
		||||
									className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300"
 | 
			
		||||
								/>
 | 
			
		||||
								<label
 | 
			
		||||
									htmlFor={repoPrivateId}
 | 
			
		||||
									className="ml-2 text-sm text-secondary-700 dark:text-secondary-200"
 | 
			
		||||
								>
 | 
			
		||||
									Private Repository (uses SSH with deploy key)
 | 
			
		||||
								</label>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
						<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
 | 
			
		||||
							Choose whether your repository is public or private to determine
 | 
			
		||||
							the appropriate access method.
 | 
			
		||||
						</p>
 | 
			
		||||
					</fieldset>
 | 
			
		||||
 | 
			
		||||
					<div>
 | 
			
		||||
						<label
 | 
			
		||||
							htmlFor={githubRepoUrlId}
 | 
			
		||||
							className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
 | 
			
		||||
						>
 | 
			
		||||
							GitHub Repository URL
 | 
			
		||||
						</label>
 | 
			
		||||
						<input
 | 
			
		||||
							id={githubRepoUrlId}
 | 
			
		||||
							type="text"
 | 
			
		||||
							value={formData.githubRepoUrl || ""}
 | 
			
		||||
							onChange={(e) =>
 | 
			
		||||
								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"
 | 
			
		||||
						/>
 | 
			
		||||
						<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
 | 
			
		||||
							SSH or HTTPS URL to your GitHub repository
 | 
			
		||||
						</p>
 | 
			
		||||
					</div>
 | 
			
		||||
 | 
			
		||||
					{formData.repositoryType === "private" && (
 | 
			
		||||
						<div>
 | 
			
		||||
							<div className="flex items-center gap-3 mb-3">
 | 
			
		||||
								<input
 | 
			
		||||
									type="checkbox"
 | 
			
		||||
									id={useCustomSshKeyId}
 | 
			
		||||
									checked={formData.useCustomSshKey}
 | 
			
		||||
									onChange={(e) => {
 | 
			
		||||
										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"
 | 
			
		||||
								/>
 | 
			
		||||
								<label
 | 
			
		||||
									htmlFor={useCustomSshKeyId}
 | 
			
		||||
									className="text-sm font-medium text-secondary-700 dark:text-secondary-200"
 | 
			
		||||
								>
 | 
			
		||||
									Set custom SSH key path
 | 
			
		||||
								</label>
 | 
			
		||||
							</div>
 | 
			
		||||
 | 
			
		||||
							{formData.useCustomSshKey && (
 | 
			
		||||
								<div>
 | 
			
		||||
									<label
 | 
			
		||||
										htmlFor={sshKeyPathId}
 | 
			
		||||
										className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
 | 
			
		||||
									>
 | 
			
		||||
										SSH Key Path
 | 
			
		||||
									</label>
 | 
			
		||||
									<input
 | 
			
		||||
										id={sshKeyPathId}
 | 
			
		||||
										type="text"
 | 
			
		||||
										value={formData.sshKeyPath || ""}
 | 
			
		||||
										onChange={(e) =>
 | 
			
		||||
											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"
 | 
			
		||||
									/>
 | 
			
		||||
									<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
 | 
			
		||||
										Path to your SSH deploy key. If not set, will auto-detect
 | 
			
		||||
										from common locations.
 | 
			
		||||
									</p>
 | 
			
		||||
 | 
			
		||||
									<div className="mt-3">
 | 
			
		||||
										<button
 | 
			
		||||
											type="button"
 | 
			
		||||
											onClick={testSshKey}
 | 
			
		||||
											disabled={
 | 
			
		||||
												sshTestResult.testing ||
 | 
			
		||||
												!formData.sshKeyPath ||
 | 
			
		||||
												!formData.githubRepoUrl
 | 
			
		||||
											}
 | 
			
		||||
											className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
 | 
			
		||||
										>
 | 
			
		||||
											{sshTestResult.testing ? "Testing..." : "Test SSH Key"}
 | 
			
		||||
										</button>
 | 
			
		||||
 | 
			
		||||
										{sshTestResult.success && (
 | 
			
		||||
											<div className="mt-2 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md">
 | 
			
		||||
												<div className="flex items-center">
 | 
			
		||||
													<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400 mr-2" />
 | 
			
		||||
													<p className="text-sm text-green-800 dark:text-green-200">
 | 
			
		||||
														{sshTestResult.message}
 | 
			
		||||
													</p>
 | 
			
		||||
												</div>
 | 
			
		||||
											</div>
 | 
			
		||||
										)}
 | 
			
		||||
 | 
			
		||||
										{sshTestResult.error && (
 | 
			
		||||
											<div className="mt-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
 | 
			
		||||
												<div className="flex items-center">
 | 
			
		||||
													<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400 mr-2" />
 | 
			
		||||
													<p className="text-sm text-red-800 dark:text-red-200">
 | 
			
		||||
														{sshTestResult.error}
 | 
			
		||||
													</p>
 | 
			
		||||
												</div>
 | 
			
		||||
											</div>
 | 
			
		||||
										)}
 | 
			
		||||
									</div>
 | 
			
		||||
								</div>
 | 
			
		||||
							)}
 | 
			
		||||
 | 
			
		||||
							{!formData.useCustomSshKey && (
 | 
			
		||||
								<p className="text-xs text-secondary-500 dark:text-secondary-400">
 | 
			
		||||
									Using auto-detection for SSH key location
 | 
			
		||||
								</p>
 | 
			
		||||
							)}
 | 
			
		||||
						</div>
 | 
			
		||||
					)}
 | 
			
		||||
 | 
			
		||||
					<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
 | 
			
		||||
						<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
							<div className="flex items-center gap-2 mb-2">
 | 
			
		||||
								<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
 | 
			
		||||
								<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
 | 
			
		||||
									Current Version
 | 
			
		||||
								</span>
 | 
			
		||||
							</div>
 | 
			
		||||
							<span className="text-lg font-mono text-secondary-900 dark:text-white">
 | 
			
		||||
								{versionInfo.currentVersion}
 | 
			
		||||
				<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
 | 
			
		||||
					{/* My Version */}
 | 
			
		||||
					<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
						<div className="flex items-center gap-2 mb-2">
 | 
			
		||||
							<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
 | 
			
		||||
							<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
 | 
			
		||||
								My Version
 | 
			
		||||
							</span>
 | 
			
		||||
						</div>
 | 
			
		||||
						<span className="text-lg font-mono text-secondary-900 dark:text-white">
 | 
			
		||||
							{versionInfo.currentVersion}
 | 
			
		||||
						</span>
 | 
			
		||||
					</div>
 | 
			
		||||
 | 
			
		||||
					{/* Latest Release */}
 | 
			
		||||
					{versionInfo.github?.latestRelease && (
 | 
			
		||||
						<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
							<div className="flex items-center gap-2 mb-2">
 | 
			
		||||
								<Download className="h-4 w-4 text-blue-600 dark:text-blue-400" />
 | 
			
		||||
								<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
 | 
			
		||||
									Latest Version
 | 
			
		||||
									Latest Release
 | 
			
		||||
								</span>
 | 
			
		||||
							</div>
 | 
			
		||||
							<span className="text-lg font-mono text-secondary-900 dark:text-white">
 | 
			
		||||
								{versionInfo.checking ? (
 | 
			
		||||
									<span className="text-blue-600 dark:text-blue-400">
 | 
			
		||||
										Checking...
 | 
			
		||||
									</span>
 | 
			
		||||
								) : versionInfo.latestVersion ? (
 | 
			
		||||
									<span
 | 
			
		||||
										className={
 | 
			
		||||
											versionInfo.isUpdateAvailable
 | 
			
		||||
												? "text-orange-600 dark:text-orange-400"
 | 
			
		||||
												: "text-green-600 dark:text-green-400"
 | 
			
		||||
										}
 | 
			
		||||
									>
 | 
			
		||||
										{versionInfo.latestVersion}
 | 
			
		||||
										{versionInfo.isUpdateAvailable && " (Update Available!)"}
 | 
			
		||||
									</span>
 | 
			
		||||
								) : (
 | 
			
		||||
									<span className="text-secondary-500 dark:text-secondary-400">
 | 
			
		||||
										Not checked
 | 
			
		||||
									</span>
 | 
			
		||||
								)}
 | 
			
		||||
							</span>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
 | 
			
		||||
					{/* Last Checked Time */}
 | 
			
		||||
					{versionInfo.last_update_check && (
 | 
			
		||||
						<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
							<div className="flex items-center gap-2 mb-2">
 | 
			
		||||
								<Clock className="h-4 w-4 text-blue-600 dark:text-blue-400" />
 | 
			
		||||
								<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
 | 
			
		||||
									Last Checked
 | 
			
		||||
							<div className="space-y-1">
 | 
			
		||||
								<span className="text-lg font-mono text-secondary-900 dark:text-white">
 | 
			
		||||
									{versionInfo.github.latestRelease.tagName}
 | 
			
		||||
								</span>
 | 
			
		||||
							</div>
 | 
			
		||||
							<span className="text-sm text-secondary-600 dark:text-secondary-400">
 | 
			
		||||
								{new Date(versionInfo.last_update_check).toLocaleString()}
 | 
			
		||||
							</span>
 | 
			
		||||
							<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
 | 
			
		||||
								Updates are checked automatically every 24 hours
 | 
			
		||||
							</p>
 | 
			
		||||
						</div>
 | 
			
		||||
					)}
 | 
			
		||||
 | 
			
		||||
					<div className="flex items-center justify-between">
 | 
			
		||||
						<div className="flex items-center gap-3">
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={checkForUpdates}
 | 
			
		||||
								disabled={versionInfo.checking}
 | 
			
		||||
								className="btn-primary flex items-center gap-2"
 | 
			
		||||
							>
 | 
			
		||||
								<Download className="h-4 w-4" />
 | 
			
		||||
								{versionInfo.checking ? "Checking..." : "Check for Updates"}
 | 
			
		||||
							</button>
 | 
			
		||||
						</div>
 | 
			
		||||
 | 
			
		||||
						{/* Save Button for Version Settings */}
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
							onClick={handleSave}
 | 
			
		||||
							disabled={!isDirty || updateSettingsMutation.isPending}
 | 
			
		||||
							className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${
 | 
			
		||||
								!isDirty || updateSettingsMutation.isPending
 | 
			
		||||
									? "bg-secondary-400 cursor-not-allowed"
 | 
			
		||||
									: "bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
 | 
			
		||||
							}`}
 | 
			
		||||
						>
 | 
			
		||||
							{updateSettingsMutation.isPending ? (
 | 
			
		||||
								<>
 | 
			
		||||
									<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
 | 
			
		||||
									Saving...
 | 
			
		||||
								</>
 | 
			
		||||
							) : (
 | 
			
		||||
								<>
 | 
			
		||||
									<Save className="h-4 w-4 mr-2" />
 | 
			
		||||
									Save Settings
 | 
			
		||||
								</>
 | 
			
		||||
							)}
 | 
			
		||||
						</button>
 | 
			
		||||
					</div>
 | 
			
		||||
 | 
			
		||||
					{versionInfo.error && (
 | 
			
		||||
						<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-lg p-4">
 | 
			
		||||
							<div className="flex">
 | 
			
		||||
								<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
 | 
			
		||||
								<div className="ml-3">
 | 
			
		||||
									<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
 | 
			
		||||
										Version Check Failed
 | 
			
		||||
									</h3>
 | 
			
		||||
									<p className="mt-1 text-sm text-red-700 dark:text-red-300">
 | 
			
		||||
										{versionInfo.error}
 | 
			
		||||
									</p>
 | 
			
		||||
									{versionInfo.error.includes("private") && (
 | 
			
		||||
										<p className="mt-2 text-xs text-red-600 dark:text-red-400">
 | 
			
		||||
											For private repositories, you may need to configure GitHub
 | 
			
		||||
											authentication or make the repository public.
 | 
			
		||||
										</p>
 | 
			
		||||
									)}
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					)}
 | 
			
		||||
 | 
			
		||||
					{/* Success Message for Version Settings */}
 | 
			
		||||
					{updateSettingsMutation.isSuccess && (
 | 
			
		||||
						<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-4">
 | 
			
		||||
							<div className="flex">
 | 
			
		||||
								<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
 | 
			
		||||
								<div className="ml-3">
 | 
			
		||||
									<p className="text-sm text-green-700 dark:text-green-300">
 | 
			
		||||
										Settings saved successfully!
 | 
			
		||||
									</p>
 | 
			
		||||
								<div className="text-xs text-secondary-500 dark:text-secondary-400">
 | 
			
		||||
									Published:{" "}
 | 
			
		||||
									{new Date(
 | 
			
		||||
										versionInfo.github.latestRelease.publishedAt,
 | 
			
		||||
									).toLocaleDateString()}
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					)}
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				{/* GitHub Repository Information */}
 | 
			
		||||
				{versionInfo.github && (
 | 
			
		||||
					<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600 mt-4">
 | 
			
		||||
						<div className="flex items-center gap-2 mb-4">
 | 
			
		||||
							<Code className="h-4 w-4 text-purple-600 dark:text-purple-400" />
 | 
			
		||||
							<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
 | 
			
		||||
								GitHub Repository Information
 | 
			
		||||
							</span>
 | 
			
		||||
						</div>
 | 
			
		||||
 | 
			
		||||
						<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
 | 
			
		||||
							{/* Repository URL */}
 | 
			
		||||
							<div className="space-y-2">
 | 
			
		||||
								<span className="text-xs font-medium text-secondary-600 dark:text-secondary-400 uppercase tracking-wide">
 | 
			
		||||
									Repository
 | 
			
		||||
								</span>
 | 
			
		||||
								<div className="flex items-center gap-2">
 | 
			
		||||
									<span className="text-sm text-secondary-900 dark:text-white font-mono">
 | 
			
		||||
										{versionInfo.github.owner}/{versionInfo.github.repo}
 | 
			
		||||
									</span>
 | 
			
		||||
									{versionInfo.github.repository && (
 | 
			
		||||
										<a
 | 
			
		||||
											href={versionInfo.github.repository}
 | 
			
		||||
											target="_blank"
 | 
			
		||||
											rel="noopener noreferrer"
 | 
			
		||||
											className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
 | 
			
		||||
										>
 | 
			
		||||
											<ExternalLink className="h-3 w-3" />
 | 
			
		||||
										</a>
 | 
			
		||||
									)}
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
 | 
			
		||||
							{/* Latest Release Info */}
 | 
			
		||||
							{versionInfo.github.latestRelease && (
 | 
			
		||||
								<div className="space-y-2">
 | 
			
		||||
									<span className="text-xs font-medium text-secondary-600 dark:text-secondary-400 uppercase tracking-wide">
 | 
			
		||||
										Release Link
 | 
			
		||||
									</span>
 | 
			
		||||
									<div className="flex items-center gap-2">
 | 
			
		||||
										{versionInfo.github.latestRelease.htmlUrl && (
 | 
			
		||||
											<a
 | 
			
		||||
												href={versionInfo.github.latestRelease.htmlUrl}
 | 
			
		||||
												target="_blank"
 | 
			
		||||
												rel="noopener noreferrer"
 | 
			
		||||
												className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm"
 | 
			
		||||
											>
 | 
			
		||||
												View Release{" "}
 | 
			
		||||
												<ExternalLink className="h-3 w-3 inline ml-1" />
 | 
			
		||||
											</a>
 | 
			
		||||
										)}
 | 
			
		||||
									</div>
 | 
			
		||||
								</div>
 | 
			
		||||
							)}
 | 
			
		||||
 | 
			
		||||
							{/* Branch Status */}
 | 
			
		||||
							{versionInfo.github.commitDifference && (
 | 
			
		||||
								<div className="space-y-2">
 | 
			
		||||
									<span className="text-xs font-medium text-secondary-600 dark:text-secondary-400 uppercase tracking-wide">
 | 
			
		||||
										Branch Status
 | 
			
		||||
									</span>
 | 
			
		||||
									<div className="text-sm">
 | 
			
		||||
										{versionInfo.github.commitDifference.commitsAhead > 0 ? (
 | 
			
		||||
											<span className="text-blue-600 dark:text-blue-400">
 | 
			
		||||
												🚀 Main branch is{" "}
 | 
			
		||||
												{versionInfo.github.commitDifference.commitsAhead}{" "}
 | 
			
		||||
												commits ahead of release
 | 
			
		||||
											</span>
 | 
			
		||||
										) : versionInfo.github.commitDifference.commitsBehind >
 | 
			
		||||
											0 ? (
 | 
			
		||||
											<span className="text-orange-600 dark:text-orange-400">
 | 
			
		||||
												📊 Main branch is{" "}
 | 
			
		||||
												{versionInfo.github.commitDifference.commitsBehind}{" "}
 | 
			
		||||
												commits behind release
 | 
			
		||||
											</span>
 | 
			
		||||
										) : (
 | 
			
		||||
											<span className="text-green-600 dark:text-green-400">
 | 
			
		||||
												✅ Main branch is in sync with release
 | 
			
		||||
											</span>
 | 
			
		||||
										)}
 | 
			
		||||
									</div>
 | 
			
		||||
								</div>
 | 
			
		||||
							)}
 | 
			
		||||
						</div>
 | 
			
		||||
 | 
			
		||||
						{/* Latest Commit Information */}
 | 
			
		||||
						{versionInfo.github.latestCommit && (
 | 
			
		||||
							<div className="mt-4 pt-4 border-t border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
								<div className="flex items-center gap-2 mb-2">
 | 
			
		||||
									<GitCommit className="h-4 w-4 text-orange-600 dark:text-orange-400" />
 | 
			
		||||
									<span className="text-xs font-medium text-secondary-600 dark:text-secondary-400 uppercase tracking-wide">
 | 
			
		||||
										Latest Commit (Rolling)
 | 
			
		||||
									</span>
 | 
			
		||||
								</div>
 | 
			
		||||
								<div className="space-y-2">
 | 
			
		||||
									<div className="flex items-center gap-2">
 | 
			
		||||
										<span className="text-sm font-mono text-secondary-900 dark:text-white">
 | 
			
		||||
											{versionInfo.github.latestCommit.sha.substring(0, 8)}
 | 
			
		||||
										</span>
 | 
			
		||||
										{versionInfo.github.latestCommit.htmlUrl && (
 | 
			
		||||
											<a
 | 
			
		||||
												href={versionInfo.github.latestCommit.htmlUrl}
 | 
			
		||||
												target="_blank"
 | 
			
		||||
												rel="noopener noreferrer"
 | 
			
		||||
												className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
 | 
			
		||||
											>
 | 
			
		||||
												<ExternalLink className="h-3 w-3" />
 | 
			
		||||
											</a>
 | 
			
		||||
										)}
 | 
			
		||||
									</div>
 | 
			
		||||
									<p className="text-sm text-secondary-700 dark:text-secondary-300">
 | 
			
		||||
										{versionInfo.github.latestCommit.message.split("\n")[0]}
 | 
			
		||||
									</p>
 | 
			
		||||
									<div className="flex items-center gap-4 text-xs text-secondary-500 dark:text-secondary-400">
 | 
			
		||||
										<span>
 | 
			
		||||
											Author: {versionInfo.github.latestCommit.author}
 | 
			
		||||
										</span>
 | 
			
		||||
										<span>
 | 
			
		||||
											Date:{" "}
 | 
			
		||||
											{new Date(
 | 
			
		||||
												versionInfo.github.latestCommit.date,
 | 
			
		||||
											).toLocaleString()}
 | 
			
		||||
										</span>
 | 
			
		||||
									</div>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						)}
 | 
			
		||||
					</div>
 | 
			
		||||
				)}
 | 
			
		||||
 | 
			
		||||
				{/* Last Checked Time */}
 | 
			
		||||
				{versionInfo.last_update_check && (
 | 
			
		||||
					<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600 mt-4">
 | 
			
		||||
						<div className="flex items-center gap-2 mb-2">
 | 
			
		||||
							<Clock className="h-4 w-4 text-blue-600 dark:text-blue-400" />
 | 
			
		||||
							<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
 | 
			
		||||
								Last Checked
 | 
			
		||||
							</span>
 | 
			
		||||
						</div>
 | 
			
		||||
						<span className="text-sm text-secondary-600 dark:text-secondary-400">
 | 
			
		||||
							{new Date(versionInfo.last_update_check).toLocaleString()}
 | 
			
		||||
						</span>
 | 
			
		||||
						<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
 | 
			
		||||
							Updates are checked automatically every 24 hours
 | 
			
		||||
						</p>
 | 
			
		||||
					</div>
 | 
			
		||||
				)}
 | 
			
		||||
 | 
			
		||||
				<div className="flex items-center justify-start mt-6">
 | 
			
		||||
					<button
 | 
			
		||||
						type="button"
 | 
			
		||||
						onClick={checkForUpdates}
 | 
			
		||||
						disabled={versionInfo.checking}
 | 
			
		||||
						className="btn-primary flex items-center gap-2"
 | 
			
		||||
					>
 | 
			
		||||
						<Download className="h-4 w-4" />
 | 
			
		||||
						{versionInfo.checking ? "Checking..." : "Check for Updates"}
 | 
			
		||||
					</button>
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				{versionInfo.error && (
 | 
			
		||||
					<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-lg p-4 mt-4">
 | 
			
		||||
						<div className="flex">
 | 
			
		||||
							<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
 | 
			
		||||
							<div className="ml-3">
 | 
			
		||||
								<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
 | 
			
		||||
									Version Check Failed
 | 
			
		||||
								</h3>
 | 
			
		||||
								<p className="mt-1 text-sm text-red-700 dark:text-red-300">
 | 
			
		||||
									{versionInfo.error}
 | 
			
		||||
								</p>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				)}
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
import { useQuery } from "@tanstack/react-query";
 | 
			
		||||
import { createContext, useContext, useMemo, useState } from "react";
 | 
			
		||||
import { createContext, useContext, useState } from "react";
 | 
			
		||||
import { isAuthReady } from "../constants/authPhases";
 | 
			
		||||
import { settingsAPI, versionAPI } from "../utils/api";
 | 
			
		||||
import { settingsAPI } from "../utils/api";
 | 
			
		||||
import { useAuth } from "./AuthContext";
 | 
			
		||||
 | 
			
		||||
const UpdateNotificationContext = createContext();
 | 
			
		||||
@@ -21,6 +21,7 @@ export const UpdateNotificationProvider = ({ children }) => {
 | 
			
		||||
	const { authPhase, isAuthenticated } = useAuth();
 | 
			
		||||
 | 
			
		||||
	// Ensure settings are loaded - but only after auth is fully ready
 | 
			
		||||
	// This reads cached update info from backend (updated by scheduler)
 | 
			
		||||
	const { data: settings, isLoading: settingsLoading } = useQuery({
 | 
			
		||||
		queryKey: ["settings"],
 | 
			
		||||
		queryFn: () => settingsAPI.get().then((res) => res.data),
 | 
			
		||||
@@ -29,31 +30,20 @@ export const UpdateNotificationProvider = ({ children }) => {
 | 
			
		||||
		enabled: isAuthReady(authPhase, isAuthenticated()),
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Memoize the enabled condition to prevent unnecessary re-evaluations
 | 
			
		||||
	const isQueryEnabled = useMemo(() => {
 | 
			
		||||
		return (
 | 
			
		||||
			isAuthReady(authPhase, isAuthenticated()) &&
 | 
			
		||||
			!!settings &&
 | 
			
		||||
			!settingsLoading
 | 
			
		||||
		);
 | 
			
		||||
	}, [authPhase, isAuthenticated, settings, settingsLoading]);
 | 
			
		||||
	// Read cached update information from settings (no GitHub API calls)
 | 
			
		||||
	// The backend scheduler updates this data periodically
 | 
			
		||||
	const updateAvailable = settings?.is_update_available && !dismissed;
 | 
			
		||||
	const updateInfo = settings
 | 
			
		||||
		? {
 | 
			
		||||
				isUpdateAvailable: settings.is_update_available,
 | 
			
		||||
				latestVersion: settings.latest_version,
 | 
			
		||||
				currentVersion: settings.current_version,
 | 
			
		||||
				last_update_check: settings.last_update_check,
 | 
			
		||||
			}
 | 
			
		||||
		: null;
 | 
			
		||||
 | 
			
		||||
	// Query for update information
 | 
			
		||||
	const {
 | 
			
		||||
		data: updateData,
 | 
			
		||||
		isLoading,
 | 
			
		||||
		error,
 | 
			
		||||
	} = useQuery({
 | 
			
		||||
		queryKey: ["updateCheck"],
 | 
			
		||||
		queryFn: () => versionAPI.checkUpdates().then((res) => res.data),
 | 
			
		||||
		staleTime: 10 * 60 * 1000, // Data stays fresh for 10 minutes
 | 
			
		||||
		refetchOnWindowFocus: false, // Don't refetch when window regains focus
 | 
			
		||||
		retry: 1,
 | 
			
		||||
		enabled: isQueryEnabled,
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	const updateAvailable = updateData?.isUpdateAvailable && !dismissed;
 | 
			
		||||
	const updateInfo = updateData;
 | 
			
		||||
	const isLoading = settingsLoading;
 | 
			
		||||
	const error = null;
 | 
			
		||||
 | 
			
		||||
	const dismissNotification = () => {
 | 
			
		||||
		setDismissed(true);
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
@@ -387,6 +388,17 @@ const HostDetail = () => {
 | 
			
		||||
											</div>
 | 
			
		||||
										)}
 | 
			
		||||
 | 
			
		||||
										{host.machine_id && (
 | 
			
		||||
											<div>
 | 
			
		||||
												<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
 | 
			
		||||
													Machine ID
 | 
			
		||||
												</p>
 | 
			
		||||
												<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm break-all">
 | 
			
		||||
													{host.machine_id}
 | 
			
		||||
												</p>
 | 
			
		||||
											</div>
 | 
			
		||||
										)}
 | 
			
		||||
 | 
			
		||||
										<div>
 | 
			
		||||
											<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
 | 
			
		||||
												Host Group
 | 
			
		||||
@@ -455,11 +467,23 @@ const HostDetail = () => {
 | 
			
		||||
 | 
			
		||||
							{/* Network Information */}
 | 
			
		||||
							{activeTab === "network" &&
 | 
			
		||||
								(host.gateway_ip ||
 | 
			
		||||
								(host.ip ||
 | 
			
		||||
									host.gateway_ip ||
 | 
			
		||||
									host.dns_servers ||
 | 
			
		||||
									host.network_interfaces) && (
 | 
			
		||||
									<div className="space-y-4">
 | 
			
		||||
										<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
 | 
			
		||||
											{host.ip && (
 | 
			
		||||
												<div>
 | 
			
		||||
													<p className="text-xs text-secondary-500 dark:text-secondary-300">
 | 
			
		||||
														IP Address
 | 
			
		||||
													</p>
 | 
			
		||||
													<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">
 | 
			
		||||
														{host.ip}
 | 
			
		||||
													</p>
 | 
			
		||||
												</div>
 | 
			
		||||
											)}
 | 
			
		||||
 | 
			
		||||
											{host.gateway_ip && (
 | 
			
		||||
												<div>
 | 
			
		||||
													<p className="text-xs text-secondary-500 dark:text-secondary-300">
 | 
			
		||||
@@ -791,6 +815,7 @@ const HostDetail = () => {
 | 
			
		||||
 | 
			
		||||
							{activeTab === "network" &&
 | 
			
		||||
								!(
 | 
			
		||||
									host.ip ||
 | 
			
		||||
									host.gateway_ip ||
 | 
			
		||||
									host.dns_servers ||
 | 
			
		||||
									host.network_interfaces
 | 
			
		||||
@@ -1059,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();
 | 
			
		||||
 | 
			
		||||
@@ -1080,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
 | 
			
		||||
@@ -1173,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:
 | 
			
		||||
							</p>
 | 
			
		||||
 | 
			
		||||
							{/* Force Install Toggle */}
 | 
			
		||||
							<div className="mb-3">
 | 
			
		||||
								<label className="flex items-center gap-2 text-sm">
 | 
			
		||||
									<input
 | 
			
		||||
										type="checkbox"
 | 
			
		||||
										checked={forceInstall}
 | 
			
		||||
										onChange={(e) => setForceInstall(e.target.checked)}
 | 
			
		||||
										className="rounded border-secondary-300 dark:border-secondary-600 text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-400 dark:bg-secondary-700"
 | 
			
		||||
									/>
 | 
			
		||||
									<span className="text-primary-800 dark:text-primary-200">
 | 
			
		||||
										Force install (bypass broken packages)
 | 
			
		||||
									</span>
 | 
			
		||||
								</label>
 | 
			
		||||
								<p className="text-xs text-primary-600 dark:text-primary-400 mt-1">
 | 
			
		||||
									Enable this if the target host has broken packages
 | 
			
		||||
									(CloudPanel, WHM, etc.) that block apt-get operations
 | 
			
		||||
								</p>
 | 
			
		||||
							</div>
 | 
			
		||||
 | 
			
		||||
							<div className="flex items-center gap-2">
 | 
			
		||||
								<input
 | 
			
		||||
									type="text"
 | 
			
		||||
									value={`curl ${getCurlFlags()} ${serverUrl}/api/v1/hosts/install -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | bash`}
 | 
			
		||||
									value={`curl ${getCurlFlags()} ${getInstallUrl()} -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | bash`}
 | 
			
		||||
									readOnly
 | 
			
		||||
									className="flex-1 px-3 py-2 border border-primary-300 dark:border-primary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
 | 
			
		||||
								/>
 | 
			
		||||
@@ -1184,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"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,23 +1,476 @@
 | 
			
		||||
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.friendlyName?.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.friendlyName
 | 
			
		||||
							?.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 (
 | 
			
		||||
			<div className="flex items-center justify-center h-64">
 | 
			
		||||
				<RefreshCw className="h-8 w-8 animate-spin text-primary-600" />
 | 
			
		||||
			</div>
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (packageError) {
 | 
			
		||||
		return (
 | 
			
		||||
			<div className="space-y-6">
 | 
			
		||||
				<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
 | 
			
		||||
					<div className="flex">
 | 
			
		||||
						<AlertTriangle className="h-5 w-5 text-danger-400" />
 | 
			
		||||
						<div className="ml-3">
 | 
			
		||||
							<h3 className="text-sm font-medium text-danger-800">
 | 
			
		||||
								Error loading package
 | 
			
		||||
							</h3>
 | 
			
		||||
							<p className="text-sm text-danger-700 mt-1">
 | 
			
		||||
								{packageError.message || "Failed to load package details"}
 | 
			
		||||
							</p>
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={() => refetchPackage()}
 | 
			
		||||
								className="mt-2 btn-danger text-xs"
 | 
			
		||||
							>
 | 
			
		||||
								Try again
 | 
			
		||||
							</button>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (!packageData) {
 | 
			
		||||
		return (
 | 
			
		||||
			<div className="space-y-6">
 | 
			
		||||
				<div className="text-center py-8">
 | 
			
		||||
					<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
 | 
			
		||||
					<p className="text-secondary-500 dark:text-secondary-300">
 | 
			
		||||
						Package not found
 | 
			
		||||
					</p>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const pkg = packageData;
 | 
			
		||||
	const stats = packageData.stats || {};
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="space-y-6">
 | 
			
		||||
			<div className="card p-8 text-center">
 | 
			
		||||
				<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
 | 
			
		||||
				<h3 className="text-lg font-medium text-secondary-900 mb-2">
 | 
			
		||||
					Package Details
 | 
			
		||||
				</h3>
 | 
			
		||||
				<p className="text-secondary-600">
 | 
			
		||||
					Detailed view for package: {packageId}
 | 
			
		||||
				</p>
 | 
			
		||||
				<p className="text-secondary-600 mt-2">
 | 
			
		||||
					This page will show package information, affected hosts, version
 | 
			
		||||
					distribution, and more.
 | 
			
		||||
				</p>
 | 
			
		||||
			{/* Header */}
 | 
			
		||||
			<div className="flex items-center justify-between">
 | 
			
		||||
				<div className="flex items-center gap-4">
 | 
			
		||||
					<button
 | 
			
		||||
						type="button"
 | 
			
		||||
						onClick={() => navigate("/packages")}
 | 
			
		||||
						className="flex items-center gap-2 text-secondary-600 hover:text-secondary-900 dark:text-secondary-400 dark:hover:text-white transition-colors"
 | 
			
		||||
					>
 | 
			
		||||
						<ArrowLeft className="h-4 w-4" />
 | 
			
		||||
						Back to Packages
 | 
			
		||||
					</button>
 | 
			
		||||
					<ChevronRight className="h-4 w-4 text-secondary-400" />
 | 
			
		||||
					<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
 | 
			
		||||
						{pkg.name}
 | 
			
		||||
					</h1>
 | 
			
		||||
				</div>
 | 
			
		||||
				<button
 | 
			
		||||
					type="button"
 | 
			
		||||
					onClick={handleRefresh}
 | 
			
		||||
					disabled={isLoadingPackage || isLoadingHosts}
 | 
			
		||||
					className="btn-outline flex items-center gap-2"
 | 
			
		||||
				>
 | 
			
		||||
					<RefreshCw
 | 
			
		||||
						className={`h-4 w-4 ${
 | 
			
		||||
							isLoadingPackage || isLoadingHosts ? "animate-spin" : ""
 | 
			
		||||
						}`}
 | 
			
		||||
					/>
 | 
			
		||||
					Refresh
 | 
			
		||||
				</button>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			{/* Package Overview */}
 | 
			
		||||
			<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
 | 
			
		||||
				{/* Main Package Info */}
 | 
			
		||||
				<div className="lg:col-span-2">
 | 
			
		||||
					<div className="card p-6">
 | 
			
		||||
						<div className="flex items-start gap-4 mb-4">
 | 
			
		||||
							<Package className="h-8 w-8 text-primary-600 flex-shrink-0 mt-1" />
 | 
			
		||||
							<div className="flex-1">
 | 
			
		||||
								<h2 className="text-xl font-semibold text-secondary-900 dark:text-white mb-2">
 | 
			
		||||
									{pkg.name}
 | 
			
		||||
								</h2>
 | 
			
		||||
								{pkg.description && (
 | 
			
		||||
									<p className="text-secondary-600 dark:text-secondary-300 mb-4">
 | 
			
		||||
										{pkg.description}
 | 
			
		||||
									</p>
 | 
			
		||||
								)}
 | 
			
		||||
								<div className="flex flex-wrap gap-4 text-sm">
 | 
			
		||||
									{pkg.category && (
 | 
			
		||||
										<div className="flex items-center gap-2">
 | 
			
		||||
											<Tag className="h-4 w-4 text-secondary-400" />
 | 
			
		||||
											<span className="text-secondary-600 dark:text-secondary-300">
 | 
			
		||||
												Category: {pkg.category}
 | 
			
		||||
											</span>
 | 
			
		||||
										</div>
 | 
			
		||||
									)}
 | 
			
		||||
									{pkg.latest_version && (
 | 
			
		||||
										<div className="flex items-center gap-2">
 | 
			
		||||
											<Download className="h-4 w-4 text-secondary-400" />
 | 
			
		||||
											<span className="text-secondary-600 dark:text-secondary-300">
 | 
			
		||||
												Latest: {pkg.latest_version}
 | 
			
		||||
											</span>
 | 
			
		||||
										</div>
 | 
			
		||||
									)}
 | 
			
		||||
									{pkg.updated_at && (
 | 
			
		||||
										<div className="flex items-center gap-2">
 | 
			
		||||
											<Calendar className="h-4 w-4 text-secondary-400" />
 | 
			
		||||
											<span className="text-secondary-600 dark:text-secondary-300">
 | 
			
		||||
												Updated: {formatRelativeTime(pkg.updated_at)}
 | 
			
		||||
											</span>
 | 
			
		||||
										</div>
 | 
			
		||||
									)}
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
 | 
			
		||||
						{/* Status Badge */}
 | 
			
		||||
						<div className="mb-4">
 | 
			
		||||
							{stats.updatesNeeded > 0 ? (
 | 
			
		||||
								stats.securityUpdates > 0 ? (
 | 
			
		||||
									<span className="badge-danger flex items-center gap-1 w-fit">
 | 
			
		||||
										<Shield className="h-3 w-3" />
 | 
			
		||||
										Security Update Available
 | 
			
		||||
									</span>
 | 
			
		||||
								) : (
 | 
			
		||||
									<span className="badge-warning w-fit">Update Available</span>
 | 
			
		||||
								)
 | 
			
		||||
							) : (
 | 
			
		||||
								<span className="badge-success w-fit">Up to Date</span>
 | 
			
		||||
							)}
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				{/* Statistics */}
 | 
			
		||||
				<div className="space-y-4">
 | 
			
		||||
					<div className="card p-4">
 | 
			
		||||
						<div className="flex items-center gap-3 mb-3">
 | 
			
		||||
							<ChartColumnBig className="h-5 w-5 text-primary-600" />
 | 
			
		||||
							<h3 className="font-medium text-secondary-900 dark:text-white">
 | 
			
		||||
								Installation Stats
 | 
			
		||||
							</h3>
 | 
			
		||||
						</div>
 | 
			
		||||
						<div className="space-y-3">
 | 
			
		||||
							<div className="flex justify-between">
 | 
			
		||||
								<span className="text-secondary-600 dark:text-secondary-300">
 | 
			
		||||
									Total Installations
 | 
			
		||||
								</span>
 | 
			
		||||
								<span className="font-semibold text-secondary-900 dark:text-white">
 | 
			
		||||
									{stats.totalInstalls || 0}
 | 
			
		||||
								</span>
 | 
			
		||||
							</div>
 | 
			
		||||
							{stats.updatesNeeded > 0 && (
 | 
			
		||||
								<div className="flex justify-between">
 | 
			
		||||
									<span className="text-secondary-600 dark:text-secondary-300">
 | 
			
		||||
										Hosts Needing Updates
 | 
			
		||||
									</span>
 | 
			
		||||
									<span className="font-semibold text-warning-600">
 | 
			
		||||
										{stats.updatesNeeded}
 | 
			
		||||
									</span>
 | 
			
		||||
								</div>
 | 
			
		||||
							)}
 | 
			
		||||
							{stats.securityUpdates > 0 && (
 | 
			
		||||
								<div className="flex justify-between">
 | 
			
		||||
									<span className="text-secondary-600 dark:text-secondary-300">
 | 
			
		||||
										Security Updates
 | 
			
		||||
									</span>
 | 
			
		||||
									<span className="font-semibold text-danger-600">
 | 
			
		||||
										{stats.securityUpdates}
 | 
			
		||||
									</span>
 | 
			
		||||
								</div>
 | 
			
		||||
							)}
 | 
			
		||||
							<div className="flex justify-between">
 | 
			
		||||
								<span className="text-secondary-600 dark:text-secondary-300">
 | 
			
		||||
									Up to Date
 | 
			
		||||
								</span>
 | 
			
		||||
								<span className="font-semibold text-success-600">
 | 
			
		||||
									{(stats.totalInstalls || 0) - (stats.updatesNeeded || 0)}
 | 
			
		||||
								</span>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			{/* Hosts List */}
 | 
			
		||||
			<div className="card">
 | 
			
		||||
				<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
					<div className="flex items-center justify-between mb-4">
 | 
			
		||||
						<div className="flex items-center gap-3">
 | 
			
		||||
							<Server className="h-5 w-5 text-primary-600" />
 | 
			
		||||
							<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
 | 
			
		||||
								Installed On Hosts ({hosts.length})
 | 
			
		||||
							</h3>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
 | 
			
		||||
					{/* Search */}
 | 
			
		||||
					<div className="relative max-w-sm">
 | 
			
		||||
						<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400" />
 | 
			
		||||
						<input
 | 
			
		||||
							type="text"
 | 
			
		||||
							placeholder="Search hosts..."
 | 
			
		||||
							value={searchTerm}
 | 
			
		||||
							onChange={(e) => {
 | 
			
		||||
								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"
 | 
			
		||||
						/>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				<div className="overflow-x-auto">
 | 
			
		||||
					{isLoadingHosts ? (
 | 
			
		||||
						<div className="flex items-center justify-center h-32">
 | 
			
		||||
							<RefreshCw className="h-6 w-6 animate-spin text-primary-600" />
 | 
			
		||||
						</div>
 | 
			
		||||
					) : hostsError ? (
 | 
			
		||||
						<div className="p-6">
 | 
			
		||||
							<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
 | 
			
		||||
								<div className="flex">
 | 
			
		||||
									<AlertTriangle className="h-5 w-5 text-danger-400" />
 | 
			
		||||
									<div className="ml-3">
 | 
			
		||||
										<h3 className="text-sm font-medium text-danger-800">
 | 
			
		||||
											Error loading hosts
 | 
			
		||||
										</h3>
 | 
			
		||||
										<p className="text-sm text-danger-700 mt-1">
 | 
			
		||||
											{hostsError.message || "Failed to load hosts"}
 | 
			
		||||
										</p>
 | 
			
		||||
									</div>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					) : filteredAndPaginatedHosts.length === 0 ? (
 | 
			
		||||
						<div className="text-center py-8">
 | 
			
		||||
							<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
 | 
			
		||||
							<p className="text-secondary-500 dark:text-secondary-300">
 | 
			
		||||
								{searchTerm
 | 
			
		||||
									? "No hosts match your search"
 | 
			
		||||
									: "No hosts have this package installed"}
 | 
			
		||||
							</p>
 | 
			
		||||
						</div>
 | 
			
		||||
					) : (
 | 
			
		||||
						<>
 | 
			
		||||
							<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
 | 
			
		||||
								<thead className="bg-secondary-50 dark:bg-secondary-700">
 | 
			
		||||
									<tr>
 | 
			
		||||
										<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
											Host
 | 
			
		||||
										</th>
 | 
			
		||||
										<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
											Current Version
 | 
			
		||||
										</th>
 | 
			
		||||
										<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
											Status
 | 
			
		||||
										</th>
 | 
			
		||||
										<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
											Last Updated
 | 
			
		||||
										</th>
 | 
			
		||||
									</tr>
 | 
			
		||||
								</thead>
 | 
			
		||||
								<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
 | 
			
		||||
									{filteredAndPaginatedHosts.map((host) => (
 | 
			
		||||
										<tr
 | 
			
		||||
											key={host.hostId}
 | 
			
		||||
											className="hover:bg-secondary-50 dark:hover:bg-secondary-700 cursor-pointer transition-colors"
 | 
			
		||||
											onClick={() => handleHostClick(host.hostId)}
 | 
			
		||||
										>
 | 
			
		||||
											<td className="px-6 py-4 whitespace-nowrap">
 | 
			
		||||
												<div className="flex items-center">
 | 
			
		||||
													<Server className="h-5 w-5 text-secondary-400 mr-3" />
 | 
			
		||||
													<div>
 | 
			
		||||
														<div className="text-sm font-medium text-secondary-900 dark:text-white">
 | 
			
		||||
															{host.friendlyName || host.hostname}
 | 
			
		||||
														</div>
 | 
			
		||||
														{host.friendlyName && host.hostname && (
 | 
			
		||||
															<div className="text-sm text-secondary-500 dark:text-secondary-300">
 | 
			
		||||
																{host.hostname}
 | 
			
		||||
															</div>
 | 
			
		||||
														)}
 | 
			
		||||
													</div>
 | 
			
		||||
												</div>
 | 
			
		||||
											</td>
 | 
			
		||||
											<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
 | 
			
		||||
												{host.currentVersion || "Unknown"}
 | 
			
		||||
											</td>
 | 
			
		||||
											<td className="px-6 py-4 whitespace-nowrap">
 | 
			
		||||
												{host.needsUpdate ? (
 | 
			
		||||
													host.isSecurityUpdate ? (
 | 
			
		||||
														<span className="badge-danger flex items-center gap-1 w-fit">
 | 
			
		||||
															<Shield className="h-3 w-3" />
 | 
			
		||||
															Security Update
 | 
			
		||||
														</span>
 | 
			
		||||
													) : (
 | 
			
		||||
														<span className="badge-warning w-fit">
 | 
			
		||||
															Update Available
 | 
			
		||||
														</span>
 | 
			
		||||
													)
 | 
			
		||||
												) : (
 | 
			
		||||
													<span className="badge-success w-fit">
 | 
			
		||||
														Up to Date
 | 
			
		||||
													</span>
 | 
			
		||||
												)}
 | 
			
		||||
											</td>
 | 
			
		||||
											<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-300">
 | 
			
		||||
												{host.lastUpdate
 | 
			
		||||
													? formatRelativeTime(host.lastUpdate)
 | 
			
		||||
													: "Never"}
 | 
			
		||||
											</td>
 | 
			
		||||
										</tr>
 | 
			
		||||
									))}
 | 
			
		||||
								</tbody>
 | 
			
		||||
							</table>
 | 
			
		||||
 | 
			
		||||
							{/* Pagination */}
 | 
			
		||||
							{totalPages > 1 && (
 | 
			
		||||
								<div className="px-6 py-3 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-600 flex items-center justify-between">
 | 
			
		||||
									<div className="flex items-center gap-2">
 | 
			
		||||
										<span className="text-sm text-secondary-700 dark:text-secondary-300">
 | 
			
		||||
											Rows per page:
 | 
			
		||||
										</span>
 | 
			
		||||
										<select
 | 
			
		||||
											value={pageSize}
 | 
			
		||||
											onChange={(e) => {
 | 
			
		||||
												setPageSize(Number(e.target.value));
 | 
			
		||||
												setCurrentPage(1);
 | 
			
		||||
											}}
 | 
			
		||||
											className="text-sm border border-secondary-300 dark:border-secondary-600 rounded px-2 py-1 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
 | 
			
		||||
										>
 | 
			
		||||
											<option value={25}>25</option>
 | 
			
		||||
											<option value={50}>50</option>
 | 
			
		||||
											<option value={100}>100</option>
 | 
			
		||||
										</select>
 | 
			
		||||
									</div>
 | 
			
		||||
									<div className="flex items-center gap-2">
 | 
			
		||||
										<button
 | 
			
		||||
											type="button"
 | 
			
		||||
											onClick={() => setCurrentPage(currentPage - 1)}
 | 
			
		||||
											disabled={currentPage === 1}
 | 
			
		||||
											className="px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-secondary-50 dark:hover:bg-secondary-700"
 | 
			
		||||
										>
 | 
			
		||||
											Previous
 | 
			
		||||
										</button>
 | 
			
		||||
										<span className="text-sm text-secondary-700 dark:text-secondary-300">
 | 
			
		||||
											Page {currentPage} of {totalPages}
 | 
			
		||||
										</span>
 | 
			
		||||
										<button
 | 
			
		||||
											type="button"
 | 
			
		||||
											onClick={() => setCurrentPage(currentPage + 1)}
 | 
			
		||||
											disabled={currentPage === totalPages}
 | 
			
		||||
											className="px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-secondary-50 dark:hover:bg-secondary-700"
 | 
			
		||||
										>
 | 
			
		||||
											Next
 | 
			
		||||
										</button>
 | 
			
		||||
									</div>
 | 
			
		||||
								</div>
 | 
			
		||||
							)}
 | 
			
		||||
						</>
 | 
			
		||||
					)}
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
 
 | 
			
		||||
@@ -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);
 | 
			
		||||
@@ -243,10 +331,14 @@ const Packages = () => {
 | 
			
		||||
		switch (column.id) {
 | 
			
		||||
			case "name":
 | 
			
		||||
				return (
 | 
			
		||||
					<div className="flex items-center">
 | 
			
		||||
						<Package className="h-5 w-5 text-secondary-400 mr-3" />
 | 
			
		||||
						<div>
 | 
			
		||||
							<div className="text-sm font-medium text-secondary-900 dark:text-white">
 | 
			
		||||
					<button
 | 
			
		||||
						type="button"
 | 
			
		||||
						onClick={() => navigate(`/packages/${pkg.id}`)}
 | 
			
		||||
						className="flex items-center text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-2 -m-2 transition-colors group w-full"
 | 
			
		||||
					>
 | 
			
		||||
						<Package className="h-5 w-5 text-secondary-400 mr-3 flex-shrink-0" />
 | 
			
		||||
						<div className="flex-1">
 | 
			
		||||
							<div className="text-sm font-medium text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400">
 | 
			
		||||
								{pkg.name}
 | 
			
		||||
							</div>
 | 
			
		||||
							{pkg.description && (
 | 
			
		||||
@@ -260,33 +352,58 @@ const Packages = () => {
 | 
			
		||||
								</div>
 | 
			
		||||
							)}
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
					</button>
 | 
			
		||||
				);
 | 
			
		||||
			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 (
 | 
			
		||||
					<button
 | 
			
		||||
						type="button"
 | 
			
		||||
						onClick={() => handleAffectedHostsClick(pkg)}
 | 
			
		||||
						onClick={() => handlePackageHostsClick(pkg)}
 | 
			
		||||
						className="text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-1 -m-1 transition-colors group"
 | 
			
		||||
						title={`Click to view all ${affectedHostsCount} affected hosts`}
 | 
			
		||||
						title={titleText}
 | 
			
		||||
					>
 | 
			
		||||
						<div className="text-sm text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400">
 | 
			
		||||
							{affectedHostsCount} host{affectedHostsCount !== 1 ? "s" : ""}
 | 
			
		||||
							{displayText}
 | 
			
		||||
						</div>
 | 
			
		||||
					</button>
 | 
			
		||||
				);
 | 
			
		||||
			}
 | 
			
		||||
			case "priority":
 | 
			
		||||
			case "status": {
 | 
			
		||||
				// Check if this package needs updates
 | 
			
		||||
				const needsUpdates = (pkg.stats?.updatesNeeded || 0) > 0;
 | 
			
		||||
 | 
			
		||||
				if (!needsUpdates) {
 | 
			
		||||
					return <span className="badge-success">Up to Date</span>;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				return pkg.isSecurityUpdate ? (
 | 
			
		||||
					<span className="badge-danger flex items-center gap-1">
 | 
			
		||||
					<span className="badge-danger">
 | 
			
		||||
						<Shield className="h-3 w-3" />
 | 
			
		||||
						Security Update
 | 
			
		||||
						Security Update Available
 | 
			
		||||
					</span>
 | 
			
		||||
				) : (
 | 
			
		||||
					<span className="badge-warning">Regular Update</span>
 | 
			
		||||
					<span className="badge-warning">Update Available</span>
 | 
			
		||||
				);
 | 
			
		||||
			}
 | 
			
		||||
			case "latestVersion":
 | 
			
		||||
				return (
 | 
			
		||||
					<div
 | 
			
		||||
@@ -305,28 +422,30 @@ const Packages = () => {
 | 
			
		||||
	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 +548,7 @@ const Packages = () => {
 | 
			
		||||
								Hosts Pending Updates
 | 
			
		||||
							</p>
 | 
			
		||||
							<p className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
			
		||||
								{uniqueAffectedHostsCount}
 | 
			
		||||
								{uniquePackageHostsCount}
 | 
			
		||||
							</p>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
@@ -490,16 +609,21 @@ const Packages = () => {
 | 
			
		||||
								</select>
 | 
			
		||||
							</div>
 | 
			
		||||
 | 
			
		||||
							{/* Security Filter */}
 | 
			
		||||
							{/* Update Status Filter */}
 | 
			
		||||
							<div className="sm:w-48">
 | 
			
		||||
								<select
 | 
			
		||||
									value={securityFilter}
 | 
			
		||||
									onChange={(e) => setSecurityFilter(e.target.value)}
 | 
			
		||||
									value={updateStatusFilter}
 | 
			
		||||
									onChange={(e) => setUpdateStatusFilter(e.target.value)}
 | 
			
		||||
									className="w-full px-3 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"
 | 
			
		||||
								>
 | 
			
		||||
									<option value="all">All Updates</option>
 | 
			
		||||
									<option value="security">Security Only</option>
 | 
			
		||||
									<option value="regular">Regular Only</option>
 | 
			
		||||
									<option value="all-packages">All Packages</option>
 | 
			
		||||
									<option value="needs-updates">
 | 
			
		||||
										Packages Needing Updates
 | 
			
		||||
									</option>
 | 
			
		||||
									<option value="security-updates">
 | 
			
		||||
										Security Updates Only
 | 
			
		||||
									</option>
 | 
			
		||||
									<option value="regular-updates">Regular Updates Only</option>
 | 
			
		||||
								</select>
 | 
			
		||||
							</div>
 | 
			
		||||
 | 
			
		||||
@@ -539,12 +663,13 @@ const Packages = () => {
 | 
			
		||||
								<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
 | 
			
		||||
								<p className="text-secondary-500 dark:text-secondary-300">
 | 
			
		||||
									{packages?.length === 0
 | 
			
		||||
										? "No packages need updates"
 | 
			
		||||
										? "No packages found"
 | 
			
		||||
										: "No packages match your filters"}
 | 
			
		||||
								</p>
 | 
			
		||||
								{packages?.length === 0 && (
 | 
			
		||||
									<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
 | 
			
		||||
										All packages are up to date across all hosts
 | 
			
		||||
										Packages will appear here once hosts start reporting their
 | 
			
		||||
										installed packages
 | 
			
		||||
									</p>
 | 
			
		||||
								)}
 | 
			
		||||
							</div>
 | 
			
		||||
@@ -571,7 +696,7 @@ const Packages = () => {
 | 
			
		||||
										</tr>
 | 
			
		||||
									</thead>
 | 
			
		||||
									<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
 | 
			
		||||
										{filteredAndSortedPackages.map((pkg) => (
 | 
			
		||||
										{paginatedPackages.map((pkg) => (
 | 
			
		||||
											<tr
 | 
			
		||||
												key={pkg.id}
 | 
			
		||||
												className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
 | 
			
		||||
@@ -591,6 +716,57 @@ const Packages = () => {
 | 
			
		||||
							</div>
 | 
			
		||||
						)}
 | 
			
		||||
					</div>
 | 
			
		||||
 | 
			
		||||
					{/* Pagination Controls */}
 | 
			
		||||
					{filteredAndSortedPackages.length > 0 && (
 | 
			
		||||
						<div className="flex items-center justify-between px-6 py-3 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
							<div className="flex items-center gap-4">
 | 
			
		||||
								<div className="flex items-center gap-2">
 | 
			
		||||
									<span className="text-sm text-secondary-700 dark:text-secondary-300">
 | 
			
		||||
										Rows per page:
 | 
			
		||||
									</span>
 | 
			
		||||
									<select
 | 
			
		||||
										value={pageSize}
 | 
			
		||||
										onChange={(e) =>
 | 
			
		||||
											handlePageSizeChange(Number(e.target.value))
 | 
			
		||||
										}
 | 
			
		||||
										className="text-sm border border-secondary-300 dark:border-secondary-600 rounded px-2 py-1 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
 | 
			
		||||
									>
 | 
			
		||||
										<option value={25}>25</option>
 | 
			
		||||
										<option value={50}>50</option>
 | 
			
		||||
										<option value={100}>100</option>
 | 
			
		||||
										<option value={200}>200</option>
 | 
			
		||||
									</select>
 | 
			
		||||
								</div>
 | 
			
		||||
								<span className="text-sm text-secondary-700 dark:text-secondary-300">
 | 
			
		||||
									{startIndex + 1}-
 | 
			
		||||
									{Math.min(endIndex, filteredAndSortedPackages.length)} of{" "}
 | 
			
		||||
									{filteredAndSortedPackages.length}
 | 
			
		||||
								</span>
 | 
			
		||||
							</div>
 | 
			
		||||
							<div className="flex items-center gap-2">
 | 
			
		||||
								<button
 | 
			
		||||
									type="button"
 | 
			
		||||
									onClick={() => setCurrentPage(currentPage - 1)}
 | 
			
		||||
									disabled={currentPage === 1}
 | 
			
		||||
									className="p-1 rounded hover:bg-secondary-100 dark:hover:bg-secondary-600 disabled:opacity-50 disabled:cursor-not-allowed"
 | 
			
		||||
								>
 | 
			
		||||
									<ChevronLeft className="h-4 w-4" />
 | 
			
		||||
								</button>
 | 
			
		||||
								<span className="text-sm text-secondary-700 dark:text-secondary-300">
 | 
			
		||||
									Page {currentPage} of {totalPages}
 | 
			
		||||
								</span>
 | 
			
		||||
								<button
 | 
			
		||||
									type="button"
 | 
			
		||||
									onClick={() => setCurrentPage(currentPage + 1)}
 | 
			
		||||
									disabled={currentPage === totalPages}
 | 
			
		||||
									className="p-1 rounded hover:bg-secondary-100 dark:hover:bg-secondary-600 disabled:opacity-50 disabled:cursor-not-allowed"
 | 
			
		||||
								>
 | 
			
		||||
									<ChevronRight className="h-4 w-4" />
 | 
			
		||||
								</button>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					)}
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,7 @@ import {
 | 
			
		||||
	User,
 | 
			
		||||
} from "lucide-react";
 | 
			
		||||
 | 
			
		||||
import { useId, useState } from "react";
 | 
			
		||||
import { useEffect, useId, useState } from "react";
 | 
			
		||||
 | 
			
		||||
import { useAuth } from "../contexts/AuthContext";
 | 
			
		||||
import { useTheme } from "../contexts/ThemeContext";
 | 
			
		||||
@@ -45,6 +45,18 @@ const Profile = () => {
 | 
			
		||||
		last_name: user?.last_name || "",
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Update profileData when user data changes
 | 
			
		||||
	useEffect(() => {
 | 
			
		||||
		if (user) {
 | 
			
		||||
			setProfileData({
 | 
			
		||||
				username: user.username || "",
 | 
			
		||||
				email: user.email || "",
 | 
			
		||||
				first_name: user.first_name || "",
 | 
			
		||||
				last_name: user.last_name || "",
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
	}, [user]);
 | 
			
		||||
 | 
			
		||||
	const [passwordData, setPasswordData] = useState({
 | 
			
		||||
		currentPassword: "",
 | 
			
		||||
		newPassword: "",
 | 
			
		||||
 
 | 
			
		||||
@@ -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,21 +14,24 @@ import {
 | 
			
		||||
	Server,
 | 
			
		||||
	Shield,
 | 
			
		||||
	ShieldCheck,
 | 
			
		||||
	Trash2,
 | 
			
		||||
	Unlock,
 | 
			
		||||
	Users,
 | 
			
		||||
	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(() => {
 | 
			
		||||
@@ -80,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)
 | 
			
		||||
@@ -138,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 [];
 | 
			
		||||
@@ -225,6 +262,56 @@ const Repositories = () => {
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
 | 
			
		||||
			{/* Delete Confirmation Modal */}
 | 
			
		||||
			{deleteModalData && (
 | 
			
		||||
				<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
 | 
			
		||||
					<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 max-w-md w-full mx-4">
 | 
			
		||||
						<div className="flex items-center mb-4">
 | 
			
		||||
							<AlertTriangle className="h-6 w-6 text-red-500 mr-3" />
 | 
			
		||||
							<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
 | 
			
		||||
								Delete Repository
 | 
			
		||||
							</h3>
 | 
			
		||||
						</div>
 | 
			
		||||
						<div className="mb-6">
 | 
			
		||||
							<p className="text-secondary-700 dark:text-secondary-300 mb-2">
 | 
			
		||||
								Are you sure you want to delete{" "}
 | 
			
		||||
								<strong>"{deleteModalData.name}"</strong>?
 | 
			
		||||
							</p>
 | 
			
		||||
							{deleteModalData.hostCount > 0 && (
 | 
			
		||||
								<p className="text-amber-600 dark:text-amber-400 text-sm">
 | 
			
		||||
									⚠️ This repository is currently assigned to{" "}
 | 
			
		||||
									{deleteModalData.hostCount} host
 | 
			
		||||
									{deleteModalData.hostCount !== 1 ? "s" : ""}.
 | 
			
		||||
								</p>
 | 
			
		||||
							)}
 | 
			
		||||
							<p className="text-red-600 dark:text-red-400 text-sm mt-2">
 | 
			
		||||
								This action cannot be undone.
 | 
			
		||||
							</p>
 | 
			
		||||
						</div>
 | 
			
		||||
						<div className="flex gap-3 justify-end">
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={cancelDelete}
 | 
			
		||||
								className="px-4 py-2 text-secondary-600 dark:text-secondary-400 hover:text-secondary-800 dark:hover:text-secondary-200 transition-colors"
 | 
			
		||||
								disabled={deleteRepositoryMutation.isPending}
 | 
			
		||||
							>
 | 
			
		||||
								Cancel
 | 
			
		||||
							</button>
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={confirmDelete}
 | 
			
		||||
								className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
 | 
			
		||||
								disabled={deleteRepositoryMutation.isPending}
 | 
			
		||||
							>
 | 
			
		||||
								{deleteRepositoryMutation.isPending
 | 
			
		||||
									? "Deleting..."
 | 
			
		||||
									: "Delete Repository"}
 | 
			
		||||
							</button>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			)}
 | 
			
		||||
 | 
			
		||||
			{/* Page Header */}
 | 
			
		||||
			<div className="flex items-center justify-between mb-6">
 | 
			
		||||
				<div>
 | 
			
		||||
@@ -415,7 +502,8 @@ const Repositories = () => {
 | 
			
		||||
										{filteredAndSortedRepositories.map((repo) => (
 | 
			
		||||
											<tr
 | 
			
		||||
												key={repo.id}
 | 
			
		||||
												className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
 | 
			
		||||
												className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors cursor-pointer"
 | 
			
		||||
												onClick={() => handleRowClick(repo)}
 | 
			
		||||
											>
 | 
			
		||||
												{visibleColumns.map((column) => (
 | 
			
		||||
													<td
 | 
			
		||||
@@ -513,19 +601,23 @@ const Repositories = () => {
 | 
			
		||||
			case "hostCount":
 | 
			
		||||
				return (
 | 
			
		||||
					<div className="flex items-center justify-center gap-1 text-sm text-secondary-900 dark:text-white">
 | 
			
		||||
						<Users className="h-4 w-4" />
 | 
			
		||||
						<span>{repo.host_count}</span>
 | 
			
		||||
						<Server className="h-4 w-4" />
 | 
			
		||||
						<span>{repo.hostCount}</span>
 | 
			
		||||
					</div>
 | 
			
		||||
				);
 | 
			
		||||
			case "actions":
 | 
			
		||||
				return (
 | 
			
		||||
					<Link
 | 
			
		||||
						to={`/repositories/${repo.id}`}
 | 
			
		||||
						className="text-primary-600 hover:text-primary-900 flex items-center gap-1"
 | 
			
		||||
					>
 | 
			
		||||
						View
 | 
			
		||||
						<Eye className="h-3 w-3" />
 | 
			
		||||
					</Link>
 | 
			
		||||
					<div className="flex items-center justify-center">
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
							onClick={(e) => handleDeleteRepository(repo, e)}
 | 
			
		||||
							className="text-orange-600 hover:text-red-900 dark:text-orange-600 dark:hover:text-red-400 flex items-center gap-1"
 | 
			
		||||
							disabled={deleteRepositoryMutation.isPending}
 | 
			
		||||
							title="Delete repository"
 | 
			
		||||
						>
 | 
			
		||||
							<Trash2 className="h-4 w-4" />
 | 
			
		||||
						</button>
 | 
			
		||||
					</div>
 | 
			
		||||
				);
 | 
			
		||||
			default:
 | 
			
		||||
				return null;
 | 
			
		||||
 
 | 
			
		||||
@@ -6,17 +6,18 @@ import {
 | 
			
		||||
	Database,
 | 
			
		||||
	Globe,
 | 
			
		||||
	Lock,
 | 
			
		||||
	Search,
 | 
			
		||||
	Server,
 | 
			
		||||
	Shield,
 | 
			
		||||
	ShieldOff,
 | 
			
		||||
	Trash2,
 | 
			
		||||
	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();
 | 
			
		||||
@@ -24,9 +25,14 @@ const RepositoryDetail = () => {
 | 
			
		||||
	const priorityId = useId();
 | 
			
		||||
	const descriptionId = useId();
 | 
			
		||||
	const { repositoryId } = useParams();
 | 
			
		||||
	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 {
 | 
			
		||||
@@ -39,6 +45,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),
 | 
			
		||||
@@ -49,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,
 | 
			
		||||
@@ -68,6 +126,19 @@ const RepositoryDetail = () => {
 | 
			
		||||
		setFormData({});
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const handleDelete = () => {
 | 
			
		||||
		setShowDeleteModal(true);
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const confirmDelete = () => {
 | 
			
		||||
		deleteRepositoryMutation.mutate();
 | 
			
		||||
		setShowDeleteModal(false);
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const cancelDelete = () => {
 | 
			
		||||
		setShowDeleteModal(false);
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	if (isLoading) {
 | 
			
		||||
		return (
 | 
			
		||||
			<div className="flex items-center justify-center h-64">
 | 
			
		||||
@@ -127,6 +198,56 @@ const RepositoryDetail = () => {
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="space-y-6">
 | 
			
		||||
			{/* Delete Confirmation Modal */}
 | 
			
		||||
			{showDeleteModal && (
 | 
			
		||||
				<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
 | 
			
		||||
					<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 max-w-md w-full mx-4">
 | 
			
		||||
						<div className="flex items-center mb-4">
 | 
			
		||||
							<AlertTriangle className="h-6 w-6 text-red-500 mr-3" />
 | 
			
		||||
							<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
 | 
			
		||||
								Delete Repository
 | 
			
		||||
							</h3>
 | 
			
		||||
						</div>
 | 
			
		||||
						<div className="mb-6">
 | 
			
		||||
							<p className="text-secondary-700 dark:text-secondary-300 mb-2">
 | 
			
		||||
								Are you sure you want to delete{" "}
 | 
			
		||||
								<strong>"{repository?.name}"</strong>?
 | 
			
		||||
							</p>
 | 
			
		||||
							{repository?.host_repositories?.length > 0 && (
 | 
			
		||||
								<p className="text-amber-600 dark:text-amber-400 text-sm">
 | 
			
		||||
									⚠️ This repository is currently assigned to{" "}
 | 
			
		||||
									{repository.host_repositories.length} host
 | 
			
		||||
									{repository.host_repositories.length !== 1 ? "s" : ""}.
 | 
			
		||||
								</p>
 | 
			
		||||
							)}
 | 
			
		||||
							<p className="text-red-600 dark:text-red-400 text-sm mt-2">
 | 
			
		||||
								This action cannot be undone.
 | 
			
		||||
							</p>
 | 
			
		||||
						</div>
 | 
			
		||||
						<div className="flex gap-3 justify-end">
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={cancelDelete}
 | 
			
		||||
								className="px-4 py-2 text-secondary-600 dark:text-secondary-400 hover:text-secondary-800 dark:hover:text-secondary-200 transition-colors"
 | 
			
		||||
								disabled={deleteRepositoryMutation.isPending}
 | 
			
		||||
							>
 | 
			
		||||
								Cancel
 | 
			
		||||
							</button>
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={confirmDelete}
 | 
			
		||||
								className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
 | 
			
		||||
								disabled={deleteRepositoryMutation.isPending}
 | 
			
		||||
							>
 | 
			
		||||
								{deleteRepositoryMutation.isPending
 | 
			
		||||
									? "Deleting..."
 | 
			
		||||
									: "Delete Repository"}
 | 
			
		||||
							</button>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			)}
 | 
			
		||||
 | 
			
		||||
			{/* Header */}
 | 
			
		||||
			<div className="flex items-center justify-between">
 | 
			
		||||
				<div className="flex items-center gap-4">
 | 
			
		||||
@@ -157,9 +278,6 @@ const RepositoryDetail = () => {
 | 
			
		||||
								{repository.is_active ? "Active" : "Inactive"}
 | 
			
		||||
							</span>
 | 
			
		||||
						</div>
 | 
			
		||||
						<p className="text-secondary-500 dark:text-secondary-300 mt-1">
 | 
			
		||||
							Repository configuration and host assignments
 | 
			
		||||
						</p>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div className="flex items-center gap-2">
 | 
			
		||||
@@ -185,15 +303,30 @@ const RepositoryDetail = () => {
 | 
			
		||||
							</button>
 | 
			
		||||
						</>
 | 
			
		||||
					) : (
 | 
			
		||||
						<button type="button" onClick={handleEdit} className="btn-primary">
 | 
			
		||||
							Edit Repository
 | 
			
		||||
						</button>
 | 
			
		||||
						<>
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={handleDelete}
 | 
			
		||||
								className="btn-outline border-red-200 text-red-600 hover:bg-red-50 hover:border-red-300 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20 dark:hover:border-red-700 flex items-center gap-2"
 | 
			
		||||
								disabled={deleteRepositoryMutation.isPending}
 | 
			
		||||
							>
 | 
			
		||||
								<Trash2 className="h-4 w-4" />
 | 
			
		||||
								{deleteRepositoryMutation.isPending ? "Deleting..." : "Delete"}
 | 
			
		||||
							</button>
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={handleEdit}
 | 
			
		||||
								className="btn-primary"
 | 
			
		||||
							>
 | 
			
		||||
								Edit Repository
 | 
			
		||||
							</button>
 | 
			
		||||
						</>
 | 
			
		||||
					)}
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			{/* Repository Information */}
 | 
			
		||||
			<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
 | 
			
		||||
			<div className="card">
 | 
			
		||||
				<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
 | 
			
		||||
					<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
 | 
			
		||||
						Repository Information
 | 
			
		||||
@@ -369,80 +502,159 @@ const RepositoryDetail = () => {
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			{/* Hosts Using This Repository */}
 | 
			
		||||
			<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
 | 
			
		||||
				<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
 | 
			
		||||
					<h2 className="text-lg font-semibold text-secondary-900 dark:text-white flex items-center gap-2">
 | 
			
		||||
						<Users className="h-5 w-5" />
 | 
			
		||||
						Hosts Using This Repository (
 | 
			
		||||
						{repository.host_repositories?.length || 0})
 | 
			
		||||
					</h2>
 | 
			
		||||
				</div>
 | 
			
		||||
				{!repository.host_repositories ||
 | 
			
		||||
				repository.host_repositories.length === 0 ? (
 | 
			
		||||
					<div className="px-6 py-12 text-center">
 | 
			
		||||
						<Server className="mx-auto h-12 w-12 text-secondary-400" />
 | 
			
		||||
						<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">
 | 
			
		||||
							No hosts using this repository
 | 
			
		||||
						</h3>
 | 
			
		||||
						<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
 | 
			
		||||
							This repository hasn't been reported by any hosts yet.
 | 
			
		||||
						</p>
 | 
			
		||||
			<div className="card">
 | 
			
		||||
				<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
					<div className="flex items-center justify-between mb-4">
 | 
			
		||||
						<div className="flex items-center gap-3">
 | 
			
		||||
							<Server className="h-5 w-5 text-primary-600" />
 | 
			
		||||
							<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
 | 
			
		||||
								Hosts Using This Repository ({hosts.length})
 | 
			
		||||
							</h3>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				) : (
 | 
			
		||||
					<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
 | 
			
		||||
						{repository.host_repositories.map((hostRepo) => (
 | 
			
		||||
							<div
 | 
			
		||||
								key={hostRepo.id}
 | 
			
		||||
								className="px-6 py-4 hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
 | 
			
		||||
							>
 | 
			
		||||
								<div className="flex items-center justify-between">
 | 
			
		||||
									<div className="flex items-center gap-3">
 | 
			
		||||
										<div
 | 
			
		||||
											className={`w-3 h-3 rounded-full ${
 | 
			
		||||
												hostRepo.hosts.status === "active"
 | 
			
		||||
													? "bg-green-500"
 | 
			
		||||
													: hostRepo.hosts.status === "pending"
 | 
			
		||||
														? "bg-yellow-500"
 | 
			
		||||
														: "bg-red-500"
 | 
			
		||||
											}`}
 | 
			
		||||
										/>
 | 
			
		||||
										<div>
 | 
			
		||||
											<Link
 | 
			
		||||
												to={`/hosts/${hostRepo.hosts.id}`}
 | 
			
		||||
												className="text-primary-600 hover:text-primary-700 font-medium"
 | 
			
		||||
											>
 | 
			
		||||
												{hostRepo.hosts.friendly_name}
 | 
			
		||||
											</Link>
 | 
			
		||||
											<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400 mt-1">
 | 
			
		||||
												<span>IP: {hostRepo.hosts.ip}</span>
 | 
			
		||||
												<span>
 | 
			
		||||
													OS: {hostRepo.hosts.os_type}{" "}
 | 
			
		||||
													{hostRepo.hosts.os_version}
 | 
			
		||||
												</span>
 | 
			
		||||
												<span>
 | 
			
		||||
													Last Update:{" "}
 | 
			
		||||
													{new Date(
 | 
			
		||||
														hostRepo.hosts.last_update,
 | 
			
		||||
													).toLocaleDateString()}
 | 
			
		||||
												</span>
 | 
			
		||||
											</div>
 | 
			
		||||
										</div>
 | 
			
		||||
 | 
			
		||||
					{/* Search */}
 | 
			
		||||
					<div className="relative max-w-sm">
 | 
			
		||||
						<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400" />
 | 
			
		||||
						<input
 | 
			
		||||
							type="text"
 | 
			
		||||
							placeholder="Search hosts..."
 | 
			
		||||
							value={searchTerm}
 | 
			
		||||
							onChange={(e) => {
 | 
			
		||||
								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"
 | 
			
		||||
						/>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				<div className="overflow-x-auto">
 | 
			
		||||
					{filteredAndPaginatedHosts.length === 0 ? (
 | 
			
		||||
						<div className="text-center py-8">
 | 
			
		||||
							<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
 | 
			
		||||
							<p className="text-secondary-500 dark:text-secondary-300">
 | 
			
		||||
								{searchTerm
 | 
			
		||||
									? "No hosts match your search"
 | 
			
		||||
									: "This repository hasn't been reported by any hosts yet."}
 | 
			
		||||
							</p>
 | 
			
		||||
						</div>
 | 
			
		||||
					) : (
 | 
			
		||||
						<>
 | 
			
		||||
							<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
 | 
			
		||||
								<thead className="bg-secondary-50 dark:bg-secondary-700">
 | 
			
		||||
									<tr>
 | 
			
		||||
										<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
											Host
 | 
			
		||||
										</th>
 | 
			
		||||
										<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
											Operating System
 | 
			
		||||
										</th>
 | 
			
		||||
										<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
											Last Checked
 | 
			
		||||
										</th>
 | 
			
		||||
										<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
 | 
			
		||||
											Last Update
 | 
			
		||||
										</th>
 | 
			
		||||
									</tr>
 | 
			
		||||
								</thead>
 | 
			
		||||
								<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
 | 
			
		||||
									{filteredAndPaginatedHosts.map((hostRepo) => (
 | 
			
		||||
										<tr
 | 
			
		||||
											key={hostRepo.id}
 | 
			
		||||
											className="hover:bg-secondary-50 dark:hover:bg-secondary-700 cursor-pointer transition-colors"
 | 
			
		||||
											onClick={() => handleHostClick(hostRepo.hosts.id)}
 | 
			
		||||
										>
 | 
			
		||||
											<td className="px-6 py-4 whitespace-nowrap">
 | 
			
		||||
												<div className="flex items-center">
 | 
			
		||||
													<div
 | 
			
		||||
														className={`w-2 h-2 rounded-full mr-3 ${
 | 
			
		||||
															hostRepo.hosts.status === "active"
 | 
			
		||||
																? "bg-success-500"
 | 
			
		||||
																: hostRepo.hosts.status === "pending"
 | 
			
		||||
																	? "bg-warning-500"
 | 
			
		||||
																	: "bg-danger-500"
 | 
			
		||||
														}`}
 | 
			
		||||
													/>
 | 
			
		||||
													<Server className="h-5 w-5 text-secondary-400 mr-3" />
 | 
			
		||||
													<div>
 | 
			
		||||
														<div className="text-sm font-medium text-secondary-900 dark:text-white">
 | 
			
		||||
															{hostRepo.hosts.friendly_name ||
 | 
			
		||||
																hostRepo.hosts.hostname}
 | 
			
		||||
														</div>
 | 
			
		||||
														{hostRepo.hosts.friendly_name &&
 | 
			
		||||
															hostRepo.hosts.hostname && (
 | 
			
		||||
																<div className="text-sm text-secondary-500 dark:text-secondary-300">
 | 
			
		||||
																	{hostRepo.hosts.hostname}
 | 
			
		||||
																</div>
 | 
			
		||||
															)}
 | 
			
		||||
													</div>
 | 
			
		||||
												</div>
 | 
			
		||||
											</td>
 | 
			
		||||
											<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
 | 
			
		||||
												{hostRepo.hosts.os_type} {hostRepo.hosts.os_version}
 | 
			
		||||
											</td>
 | 
			
		||||
											<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-300">
 | 
			
		||||
												{hostRepo.last_checked
 | 
			
		||||
													? formatRelativeTime(hostRepo.last_checked)
 | 
			
		||||
													: "Never"}
 | 
			
		||||
											</td>
 | 
			
		||||
											<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-300">
 | 
			
		||||
												{hostRepo.hosts.last_update
 | 
			
		||||
													? formatRelativeTime(hostRepo.hosts.last_update)
 | 
			
		||||
													: "Never"}
 | 
			
		||||
											</td>
 | 
			
		||||
										</tr>
 | 
			
		||||
									))}
 | 
			
		||||
								</tbody>
 | 
			
		||||
							</table>
 | 
			
		||||
 | 
			
		||||
							{/* Pagination */}
 | 
			
		||||
							{totalPages > 1 && (
 | 
			
		||||
								<div className="px-6 py-3 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-600 flex items-center justify-between">
 | 
			
		||||
									<div className="flex items-center gap-2">
 | 
			
		||||
										<span className="text-sm text-secondary-700 dark:text-secondary-300">
 | 
			
		||||
											Rows per page:
 | 
			
		||||
										</span>
 | 
			
		||||
										<select
 | 
			
		||||
											value={pageSize}
 | 
			
		||||
											onChange={(e) => {
 | 
			
		||||
												setPageSize(Number(e.target.value));
 | 
			
		||||
												setCurrentPage(1);
 | 
			
		||||
											}}
 | 
			
		||||
											className="text-sm border border-secondary-300 dark:border-secondary-600 rounded px-2 py-1 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
 | 
			
		||||
										>
 | 
			
		||||
											<option value={25}>25</option>
 | 
			
		||||
											<option value={50}>50</option>
 | 
			
		||||
											<option value={100}>100</option>
 | 
			
		||||
										</select>
 | 
			
		||||
									</div>
 | 
			
		||||
									<div className="flex items-center gap-4">
 | 
			
		||||
										<div className="text-center">
 | 
			
		||||
											<div className="text-xs text-secondary-500 dark:text-secondary-400">
 | 
			
		||||
												Last Checked
 | 
			
		||||
											</div>
 | 
			
		||||
											<div className="text-sm text-secondary-900 dark:text-white">
 | 
			
		||||
												{new Date(hostRepo.last_checked).toLocaleDateString()}
 | 
			
		||||
											</div>
 | 
			
		||||
										</div>
 | 
			
		||||
									<div className="flex items-center gap-2">
 | 
			
		||||
										<button
 | 
			
		||||
											type="button"
 | 
			
		||||
											onClick={() => setCurrentPage(currentPage - 1)}
 | 
			
		||||
											disabled={currentPage === 1}
 | 
			
		||||
											className="px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-secondary-50 dark:hover:bg-secondary-700"
 | 
			
		||||
										>
 | 
			
		||||
											Previous
 | 
			
		||||
										</button>
 | 
			
		||||
										<span className="text-sm text-secondary-700 dark:text-secondary-300">
 | 
			
		||||
											Page {currentPage} of {totalPages}
 | 
			
		||||
										</span>
 | 
			
		||||
										<button
 | 
			
		||||
											type="button"
 | 
			
		||||
											onClick={() => setCurrentPage(currentPage + 1)}
 | 
			
		||||
											disabled={currentPage === totalPages}
 | 
			
		||||
											className="px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-secondary-50 dark:hover:bg-secondary-700"
 | 
			
		||||
										>
 | 
			
		||||
											Next
 | 
			
		||||
										</button>
 | 
			
		||||
									</div>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						))}
 | 
			
		||||
					</div>
 | 
			
		||||
				)}
 | 
			
		||||
							)}
 | 
			
		||||
						</>
 | 
			
		||||
					)}
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
 
 | 
			
		||||
@@ -5,11 +5,13 @@ import {
 | 
			
		||||
	Clock,
 | 
			
		||||
	Code,
 | 
			
		||||
	Download,
 | 
			
		||||
	Image,
 | 
			
		||||
	Plus,
 | 
			
		||||
	Save,
 | 
			
		||||
	Server,
 | 
			
		||||
	Settings as SettingsIcon,
 | 
			
		||||
	Shield,
 | 
			
		||||
	Upload,
 | 
			
		||||
	X,
 | 
			
		||||
} from "lucide-react";
 | 
			
		||||
 | 
			
		||||
@@ -80,6 +82,15 @@ const Settings = () => {
 | 
			
		||||
	});
 | 
			
		||||
	const [showUploadModal, setShowUploadModal] = useState(false);
 | 
			
		||||
 | 
			
		||||
	// Logo management state
 | 
			
		||||
	const [logoUploadState, setLogoUploadState] = useState({
 | 
			
		||||
		dark: { uploading: false, error: null },
 | 
			
		||||
		light: { uploading: false, error: null },
 | 
			
		||||
		favicon: { uploading: false, error: null },
 | 
			
		||||
	});
 | 
			
		||||
	const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
 | 
			
		||||
	const [selectedLogoType, setSelectedLogoType] = useState("dark");
 | 
			
		||||
 | 
			
		||||
	// Version checking state
 | 
			
		||||
	const [versionInfo, setVersionInfo] = useState({
 | 
			
		||||
		currentVersion: null, // Will be loaded from API
 | 
			
		||||
@@ -192,6 +203,37 @@ const Settings = () => {
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Logo upload mutation
 | 
			
		||||
	const uploadLogoMutation = useMutation({
 | 
			
		||||
		mutationFn: ({ logoType, fileContent, fileName }) =>
 | 
			
		||||
			fetch("/api/v1/settings/logos/upload", {
 | 
			
		||||
				method: "POST",
 | 
			
		||||
				headers: {
 | 
			
		||||
					"Content-Type": "application/json",
 | 
			
		||||
					Authorization: `Bearer ${localStorage.getItem("token")}`,
 | 
			
		||||
				},
 | 
			
		||||
				body: JSON.stringify({ logoType, fileContent, fileName }),
 | 
			
		||||
			}).then((res) => res.json()),
 | 
			
		||||
		onSuccess: (_data, variables) => {
 | 
			
		||||
			queryClient.invalidateQueries(["settings"]);
 | 
			
		||||
			setLogoUploadState((prev) => ({
 | 
			
		||||
				...prev,
 | 
			
		||||
				[variables.logoType]: { uploading: false, error: null },
 | 
			
		||||
			}));
 | 
			
		||||
			setShowLogoUploadModal(false);
 | 
			
		||||
		},
 | 
			
		||||
		onError: (error, variables) => {
 | 
			
		||||
			console.error("Upload logo error:", error);
 | 
			
		||||
			setLogoUploadState((prev) => ({
 | 
			
		||||
				...prev,
 | 
			
		||||
				[variables.logoType]: {
 | 
			
		||||
					uploading: false,
 | 
			
		||||
					error: error.message || "Failed to upload logo",
 | 
			
		||||
				},
 | 
			
		||||
			}));
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Load current version on component mount
 | 
			
		||||
	useEffect(() => {
 | 
			
		||||
		const loadCurrentVersion = async () => {
 | 
			
		||||
@@ -556,6 +598,181 @@ const Settings = () => {
 | 
			
		||||
								</p>
 | 
			
		||||
							</div>
 | 
			
		||||
 | 
			
		||||
							{/* Logo Management Section */}
 | 
			
		||||
							<div className="mt-6 pt-6 border-t border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
								<div className="flex items-center mb-4">
 | 
			
		||||
									<Image className="h-5 w-5 text-primary-600 mr-2" />
 | 
			
		||||
									<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
 | 
			
		||||
										Logo & Branding
 | 
			
		||||
									</h3>
 | 
			
		||||
								</div>
 | 
			
		||||
								<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-4">
 | 
			
		||||
									Customize your PatchMon installation with custom logos and
 | 
			
		||||
									favicon.
 | 
			
		||||
								</p>
 | 
			
		||||
 | 
			
		||||
								<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
 | 
			
		||||
									{/* Dark Logo */}
 | 
			
		||||
									<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
										<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3">
 | 
			
		||||
											Dark Logo
 | 
			
		||||
										</h4>
 | 
			
		||||
										{settings?.logo_dark && (
 | 
			
		||||
											<div className="flex items-center justify-center p-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-3">
 | 
			
		||||
												<img
 | 
			
		||||
													src={settings.logo_dark}
 | 
			
		||||
													alt="Dark Logo"
 | 
			
		||||
													className="max-h-12 max-w-full object-contain"
 | 
			
		||||
													onError={(e) => {
 | 
			
		||||
														e.target.style.display = "none";
 | 
			
		||||
													}}
 | 
			
		||||
												/>
 | 
			
		||||
											</div>
 | 
			
		||||
										)}
 | 
			
		||||
										<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-3 truncate">
 | 
			
		||||
											{settings?.logo_dark
 | 
			
		||||
												? settings.logo_dark.split("/").pop()
 | 
			
		||||
												: "Default"}
 | 
			
		||||
										</p>
 | 
			
		||||
										<button
 | 
			
		||||
											type="button"
 | 
			
		||||
											onClick={() => {
 | 
			
		||||
												setSelectedLogoType("dark");
 | 
			
		||||
												setShowLogoUploadModal(true);
 | 
			
		||||
											}}
 | 
			
		||||
											disabled={logoUploadState.dark.uploading}
 | 
			
		||||
											className="w-full btn-outline flex items-center justify-center gap-2 text-sm py-2"
 | 
			
		||||
										>
 | 
			
		||||
											{logoUploadState.dark.uploading ? (
 | 
			
		||||
												<>
 | 
			
		||||
													<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-current"></div>
 | 
			
		||||
													Uploading...
 | 
			
		||||
												</>
 | 
			
		||||
											) : (
 | 
			
		||||
												<>
 | 
			
		||||
													<Upload className="h-3 w-3" />
 | 
			
		||||
													Upload
 | 
			
		||||
												</>
 | 
			
		||||
											)}
 | 
			
		||||
										</button>
 | 
			
		||||
										{logoUploadState.dark.error && (
 | 
			
		||||
											<p className="text-xs text-red-600 dark:text-red-400 mt-2">
 | 
			
		||||
												{logoUploadState.dark.error}
 | 
			
		||||
											</p>
 | 
			
		||||
										)}
 | 
			
		||||
									</div>
 | 
			
		||||
 | 
			
		||||
									{/* Light Logo */}
 | 
			
		||||
									<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
										<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3">
 | 
			
		||||
											Light Logo
 | 
			
		||||
										</h4>
 | 
			
		||||
										{settings?.logo_light && (
 | 
			
		||||
											<div className="flex items-center justify-center p-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-3">
 | 
			
		||||
												<img
 | 
			
		||||
													src={settings.logo_light}
 | 
			
		||||
													alt="Light Logo"
 | 
			
		||||
													className="max-h-12 max-w-full object-contain"
 | 
			
		||||
													onError={(e) => {
 | 
			
		||||
														e.target.style.display = "none";
 | 
			
		||||
													}}
 | 
			
		||||
												/>
 | 
			
		||||
											</div>
 | 
			
		||||
										)}
 | 
			
		||||
										<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-3 truncate">
 | 
			
		||||
											{settings?.logo_light
 | 
			
		||||
												? settings.logo_light.split("/").pop()
 | 
			
		||||
												: "Default"}
 | 
			
		||||
										</p>
 | 
			
		||||
										<button
 | 
			
		||||
											type="button"
 | 
			
		||||
											onClick={() => {
 | 
			
		||||
												setSelectedLogoType("light");
 | 
			
		||||
												setShowLogoUploadModal(true);
 | 
			
		||||
											}}
 | 
			
		||||
											disabled={logoUploadState.light.uploading}
 | 
			
		||||
											className="w-full btn-outline flex items-center justify-center gap-2 text-sm py-2"
 | 
			
		||||
										>
 | 
			
		||||
											{logoUploadState.light.uploading ? (
 | 
			
		||||
												<>
 | 
			
		||||
													<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-current"></div>
 | 
			
		||||
													Uploading...
 | 
			
		||||
												</>
 | 
			
		||||
											) : (
 | 
			
		||||
												<>
 | 
			
		||||
													<Upload className="h-3 w-3" />
 | 
			
		||||
													Upload
 | 
			
		||||
												</>
 | 
			
		||||
											)}
 | 
			
		||||
										</button>
 | 
			
		||||
										{logoUploadState.light.error && (
 | 
			
		||||
											<p className="text-xs text-red-600 dark:text-red-400 mt-2">
 | 
			
		||||
												{logoUploadState.light.error}
 | 
			
		||||
											</p>
 | 
			
		||||
										)}
 | 
			
		||||
									</div>
 | 
			
		||||
 | 
			
		||||
									{/* Favicon */}
 | 
			
		||||
									<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
										<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3">
 | 
			
		||||
											Favicon
 | 
			
		||||
										</h4>
 | 
			
		||||
										{settings?.favicon && (
 | 
			
		||||
											<div className="flex items-center justify-center p-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-3">
 | 
			
		||||
												<img
 | 
			
		||||
													src={settings.favicon}
 | 
			
		||||
													alt="Favicon"
 | 
			
		||||
													className="h-8 w-8 object-contain"
 | 
			
		||||
													onError={(e) => {
 | 
			
		||||
														e.target.style.display = "none";
 | 
			
		||||
													}}
 | 
			
		||||
												/>
 | 
			
		||||
											</div>
 | 
			
		||||
										)}
 | 
			
		||||
										<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-3 truncate">
 | 
			
		||||
											{settings?.favicon
 | 
			
		||||
												? settings.favicon.split("/").pop()
 | 
			
		||||
												: "Default"}
 | 
			
		||||
										</p>
 | 
			
		||||
										<button
 | 
			
		||||
											type="button"
 | 
			
		||||
											onClick={() => {
 | 
			
		||||
												setSelectedLogoType("favicon");
 | 
			
		||||
												setShowLogoUploadModal(true);
 | 
			
		||||
											}}
 | 
			
		||||
											disabled={logoUploadState.favicon.uploading}
 | 
			
		||||
											className="w-full btn-outline flex items-center justify-center gap-2 text-sm py-2"
 | 
			
		||||
										>
 | 
			
		||||
											{logoUploadState.favicon.uploading ? (
 | 
			
		||||
												<>
 | 
			
		||||
													<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-current"></div>
 | 
			
		||||
													Uploading...
 | 
			
		||||
												</>
 | 
			
		||||
											) : (
 | 
			
		||||
												<>
 | 
			
		||||
													<Upload className="h-3 w-3" />
 | 
			
		||||
													Upload
 | 
			
		||||
												</>
 | 
			
		||||
											)}
 | 
			
		||||
										</button>
 | 
			
		||||
										{logoUploadState.favicon.error && (
 | 
			
		||||
											<p className="text-xs text-red-600 dark:text-red-400 mt-2">
 | 
			
		||||
												{logoUploadState.favicon.error}
 | 
			
		||||
											</p>
 | 
			
		||||
										)}
 | 
			
		||||
									</div>
 | 
			
		||||
								</div>
 | 
			
		||||
 | 
			
		||||
								<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-md">
 | 
			
		||||
									<p className="text-xs text-blue-700 dark:text-blue-300">
 | 
			
		||||
										<strong>Supported formats:</strong> PNG, JPG, SVG.{" "}
 | 
			
		||||
										<strong>Max size:</strong> 5MB.
 | 
			
		||||
										<strong> Recommended sizes:</strong> 200x60px for logos,
 | 
			
		||||
										32x32px for favicon.
 | 
			
		||||
									</p>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
 | 
			
		||||
							{/* Update Interval */}
 | 
			
		||||
							<div>
 | 
			
		||||
								<label
 | 
			
		||||
@@ -1319,6 +1536,18 @@ const Settings = () => {
 | 
			
		||||
					error={uploadAgentMutation.error}
 | 
			
		||||
				/>
 | 
			
		||||
			)}
 | 
			
		||||
 | 
			
		||||
			{/* Logo Upload Modal */}
 | 
			
		||||
			{showLogoUploadModal && (
 | 
			
		||||
				<LogoUploadModal
 | 
			
		||||
					isOpen={showLogoUploadModal}
 | 
			
		||||
					onClose={() => setShowLogoUploadModal(false)}
 | 
			
		||||
					onSubmit={uploadLogoMutation.mutate}
 | 
			
		||||
					isLoading={uploadLogoMutation.isPending}
 | 
			
		||||
					error={uploadLogoMutation.error}
 | 
			
		||||
					logoType={selectedLogoType}
 | 
			
		||||
				/>
 | 
			
		||||
			)}
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
@@ -1467,4 +1696,181 @@ const AgentUploadModal = ({ isOpen, onClose, onSubmit, isLoading, error }) => {
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Logo Upload Modal Component
 | 
			
		||||
const LogoUploadModal = ({
 | 
			
		||||
	isOpen,
 | 
			
		||||
	onClose,
 | 
			
		||||
	onSubmit,
 | 
			
		||||
	isLoading,
 | 
			
		||||
	error,
 | 
			
		||||
	logoType,
 | 
			
		||||
}) => {
 | 
			
		||||
	const [selectedFile, setSelectedFile] = useState(null);
 | 
			
		||||
	const [previewUrl, setPreviewUrl] = useState(null);
 | 
			
		||||
	const [uploadError, setUploadError] = useState("");
 | 
			
		||||
 | 
			
		||||
	const handleFileSelect = (e) => {
 | 
			
		||||
		const file = e.target.files[0];
 | 
			
		||||
		if (file) {
 | 
			
		||||
			// Validate file type
 | 
			
		||||
			const allowedTypes = [
 | 
			
		||||
				"image/png",
 | 
			
		||||
				"image/jpeg",
 | 
			
		||||
				"image/jpg",
 | 
			
		||||
				"image/svg+xml",
 | 
			
		||||
			];
 | 
			
		||||
			if (!allowedTypes.includes(file.type)) {
 | 
			
		||||
				setUploadError("Please select a PNG, JPG, or SVG file");
 | 
			
		||||
				return;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Validate file size (5MB limit)
 | 
			
		||||
			if (file.size > 5 * 1024 * 1024) {
 | 
			
		||||
				setUploadError("File size must be less than 5MB");
 | 
			
		||||
				return;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			setSelectedFile(file);
 | 
			
		||||
			setUploadError("");
 | 
			
		||||
 | 
			
		||||
			// Create preview URL
 | 
			
		||||
			const url = URL.createObjectURL(file);
 | 
			
		||||
			setPreviewUrl(url);
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const handleSubmit = (e) => {
 | 
			
		||||
		e.preventDefault();
 | 
			
		||||
		setUploadError("");
 | 
			
		||||
 | 
			
		||||
		if (!selectedFile) {
 | 
			
		||||
			setUploadError("Please select a file");
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Convert file to base64
 | 
			
		||||
		const reader = new FileReader();
 | 
			
		||||
		reader.onload = (event) => {
 | 
			
		||||
			const base64 = event.target.result;
 | 
			
		||||
			onSubmit({
 | 
			
		||||
				logoType,
 | 
			
		||||
				fileContent: base64,
 | 
			
		||||
				fileName: selectedFile.name,
 | 
			
		||||
			});
 | 
			
		||||
		};
 | 
			
		||||
		reader.readAsDataURL(selectedFile);
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const handleClose = () => {
 | 
			
		||||
		setSelectedFile(null);
 | 
			
		||||
		setPreviewUrl(null);
 | 
			
		||||
		setUploadError("");
 | 
			
		||||
		onClose();
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	if (!isOpen) return null;
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
 | 
			
		||||
			<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
 | 
			
		||||
				<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
					<div className="flex items-center justify-between">
 | 
			
		||||
						<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
 | 
			
		||||
							Upload{" "}
 | 
			
		||||
							{logoType === "favicon"
 | 
			
		||||
								? "Favicon"
 | 
			
		||||
								: `${logoType.charAt(0).toUpperCase() + logoType.slice(1)} Logo`}
 | 
			
		||||
						</h3>
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
							onClick={handleClose}
 | 
			
		||||
							className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
 | 
			
		||||
						>
 | 
			
		||||
							<X className="h-5 w-5" />
 | 
			
		||||
						</button>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				<form onSubmit={handleSubmit} className="px-6 py-4">
 | 
			
		||||
					<div className="space-y-4">
 | 
			
		||||
						<div>
 | 
			
		||||
							<label className="block">
 | 
			
		||||
								<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
 | 
			
		||||
									Select File
 | 
			
		||||
								</span>
 | 
			
		||||
								<input
 | 
			
		||||
									type="file"
 | 
			
		||||
									accept="image/png,image/jpeg,image/jpg,image/svg+xml"
 | 
			
		||||
									onChange={handleFileSelect}
 | 
			
		||||
									className="block w-full text-sm text-secondary-500 dark:text-secondary-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900 dark:file:text-primary-200"
 | 
			
		||||
								/>
 | 
			
		||||
							</label>
 | 
			
		||||
							<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
 | 
			
		||||
								Supported formats: PNG, JPG, SVG. Max size: 5MB.
 | 
			
		||||
								{logoType === "favicon"
 | 
			
		||||
									? " Recommended: 32x32px SVG."
 | 
			
		||||
									: " Recommended: 200x60px."}
 | 
			
		||||
							</p>
 | 
			
		||||
						</div>
 | 
			
		||||
 | 
			
		||||
						{previewUrl && (
 | 
			
		||||
							<div>
 | 
			
		||||
								<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
 | 
			
		||||
									Preview
 | 
			
		||||
								</div>
 | 
			
		||||
								<div className="flex items-center justify-center p-4 bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
									<img
 | 
			
		||||
										src={previewUrl}
 | 
			
		||||
										alt="Preview"
 | 
			
		||||
										className={`object-contain ${
 | 
			
		||||
											logoType === "favicon" ? "h-8 w-8" : "max-h-16 max-w-full"
 | 
			
		||||
										}`}
 | 
			
		||||
									/>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						)}
 | 
			
		||||
 | 
			
		||||
						{(uploadError || error) && (
 | 
			
		||||
							<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
 | 
			
		||||
								<p className="text-sm text-red-800 dark:text-red-200">
 | 
			
		||||
									{uploadError ||
 | 
			
		||||
										error?.response?.data?.error ||
 | 
			
		||||
										error?.message}
 | 
			
		||||
								</p>
 | 
			
		||||
							</div>
 | 
			
		||||
						)}
 | 
			
		||||
 | 
			
		||||
						<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3">
 | 
			
		||||
							<div className="flex">
 | 
			
		||||
								<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mr-2 mt-0.5" />
 | 
			
		||||
								<div className="text-sm text-yellow-800 dark:text-yellow-200">
 | 
			
		||||
									<p className="font-medium">Important:</p>
 | 
			
		||||
									<ul className="mt-1 list-disc list-inside space-y-1">
 | 
			
		||||
										<li>This will replace the current {logoType} logo</li>
 | 
			
		||||
										<li>A backup will be created automatically</li>
 | 
			
		||||
										<li>The change will be applied immediately</li>
 | 
			
		||||
									</ul>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
 | 
			
		||||
					<div className="flex justify-end gap-3 mt-6">
 | 
			
		||||
						<button type="button" onClick={handleClose} className="btn-outline">
 | 
			
		||||
							Cancel
 | 
			
		||||
						</button>
 | 
			
		||||
						<button
 | 
			
		||||
							type="submit"
 | 
			
		||||
							disabled={isLoading || !selectedFile}
 | 
			
		||||
							className="btn-primary"
 | 
			
		||||
						>
 | 
			
		||||
							{isLoading ? "Uploading..." : "Upload Logo"}
 | 
			
		||||
						</button>
 | 
			
		||||
					</div>
 | 
			
		||||
				</form>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default Settings;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,47 +1,751 @@
 | 
			
		||||
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 [activeTab, setActiveTab] = useState("proxmox");
 | 
			
		||||
	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("");
 | 
			
		||||
	const [force_proxmox_install, setForceProxmoxInstall] = useState(false);
 | 
			
		||||
 | 
			
		||||
	// 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({});
 | 
			
		||||
 | 
			
		||||
	// 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);
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	// 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 (
 | 
			
		||||
		<SettingsLayout>
 | 
			
		||||
			<div className="space-y-6">
 | 
			
		||||
				{/* Header */}
 | 
			
		||||
				<div className="flex items-center justify-between">
 | 
			
		||||
					<div>
 | 
			
		||||
						<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
 | 
			
		||||
							Integrations
 | 
			
		||||
						</h1>
 | 
			
		||||
						<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
 | 
			
		||||
							Connect PatchMon to third-party services
 | 
			
		||||
						</p>
 | 
			
		||||
					</div>
 | 
			
		||||
				<div>
 | 
			
		||||
					<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
 | 
			
		||||
						Integrations
 | 
			
		||||
					</h1>
 | 
			
		||||
					<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
 | 
			
		||||
						Manage auto-enrollment tokens for Proxmox and other integrations
 | 
			
		||||
					</p>
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				{/* Coming Soon Card */}
 | 
			
		||||
				<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
 | 
			
		||||
					<div className="flex items-center gap-4">
 | 
			
		||||
						<div className="flex-shrink-0">
 | 
			
		||||
							<div className="w-12 h-12 bg-secondary-100 dark:bg-secondary-700 rounded-lg flex items-center justify-center">
 | 
			
		||||
								<Plug className="h-6 w-6 text-secondary-700 dark:text-secondary-200" />
 | 
			
		||||
				{/* Tabs Navigation */}
 | 
			
		||||
				<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg overflow-hidden">
 | 
			
		||||
					<div className="border-b border-secondary-200 dark:border-secondary-600 flex">
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
							onClick={() => handleTabChange("proxmox")}
 | 
			
		||||
							className={`px-6 py-3 text-sm font-medium ${
 | 
			
		||||
								activeTab === "proxmox"
 | 
			
		||||
									? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500 bg-primary-50 dark:bg-primary-900/20"
 | 
			
		||||
									: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
 | 
			
		||||
							}`}
 | 
			
		||||
						>
 | 
			
		||||
							Proxmox LXC
 | 
			
		||||
						</button>
 | 
			
		||||
						{/* Future tabs can be added here */}
 | 
			
		||||
					</div>
 | 
			
		||||
 | 
			
		||||
					{/* Tab Content */}
 | 
			
		||||
					<div className="p-6">
 | 
			
		||||
						{/* Proxmox Tab */}
 | 
			
		||||
						{activeTab === "proxmox" && (
 | 
			
		||||
							<div className="space-y-6">
 | 
			
		||||
								{/* Header with New Token Button */}
 | 
			
		||||
								<div className="flex items-center justify-between">
 | 
			
		||||
									<div className="flex items-center gap-3">
 | 
			
		||||
										<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900 rounded-lg flex items-center justify-center">
 | 
			
		||||
											<Server className="h-5 w-5 text-primary-600 dark:text-primary-400" />
 | 
			
		||||
										</div>
 | 
			
		||||
										<div>
 | 
			
		||||
											<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
 | 
			
		||||
												Proxmox LXC Auto-Enrollment
 | 
			
		||||
											</h3>
 | 
			
		||||
											<p className="text-sm text-secondary-600 dark:text-secondary-400">
 | 
			
		||||
												Automatically discover and enroll LXC containers from
 | 
			
		||||
												Proxmox hosts
 | 
			
		||||
											</p>
 | 
			
		||||
										</div>
 | 
			
		||||
									</div>
 | 
			
		||||
									<button
 | 
			
		||||
										type="button"
 | 
			
		||||
										onClick={() => setShowCreateModal(true)}
 | 
			
		||||
										className="btn-primary flex items-center gap-2"
 | 
			
		||||
									>
 | 
			
		||||
										<Plus className="h-4 w-4" />
 | 
			
		||||
										New Token
 | 
			
		||||
									</button>
 | 
			
		||||
								</div>
 | 
			
		||||
 | 
			
		||||
								{/* Token List */}
 | 
			
		||||
								{loading ? (
 | 
			
		||||
									<div className="text-center py-8">
 | 
			
		||||
										<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
 | 
			
		||||
									</div>
 | 
			
		||||
								) : tokens.length === 0 ? (
 | 
			
		||||
									<div className="text-center py-8 text-secondary-600 dark:text-secondary-400">
 | 
			
		||||
										<p>No auto-enrollment tokens created yet.</p>
 | 
			
		||||
										<p className="text-sm mt-2">
 | 
			
		||||
											Create a token to enable automatic host enrollment from
 | 
			
		||||
											Proxmox.
 | 
			
		||||
										</p>
 | 
			
		||||
									</div>
 | 
			
		||||
								) : (
 | 
			
		||||
									<div className="space-y-3">
 | 
			
		||||
										{tokens.map((token) => (
 | 
			
		||||
											<div
 | 
			
		||||
												key={token.id}
 | 
			
		||||
												className="border border-secondary-200 dark:border-secondary-600 rounded-lg p-4 hover:border-primary-300 dark:hover:border-primary-700 transition-colors"
 | 
			
		||||
											>
 | 
			
		||||
												<div className="flex justify-between items-start">
 | 
			
		||||
													<div className="flex-1">
 | 
			
		||||
														<div className="flex items-center gap-2 flex-wrap">
 | 
			
		||||
															<h4 className="font-medium text-secondary-900 dark:text-white">
 | 
			
		||||
																{token.token_name}
 | 
			
		||||
															</h4>
 | 
			
		||||
															<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
 | 
			
		||||
																Proxmox LXC
 | 
			
		||||
															</span>
 | 
			
		||||
															{token.is_active ? (
 | 
			
		||||
																<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
 | 
			
		||||
																	Active
 | 
			
		||||
																</span>
 | 
			
		||||
															) : (
 | 
			
		||||
																<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
 | 
			
		||||
																	Inactive
 | 
			
		||||
																</span>
 | 
			
		||||
															)}
 | 
			
		||||
														</div>
 | 
			
		||||
														<div className="mt-2 space-y-1 text-sm text-secondary-600 dark:text-secondary-400">
 | 
			
		||||
															<div className="flex items-center gap-2">
 | 
			
		||||
																<span className="font-mono text-xs bg-secondary-100 dark:bg-secondary-700 px-2 py-1 rounded">
 | 
			
		||||
																	{token.token_key}
 | 
			
		||||
																</span>
 | 
			
		||||
																<button
 | 
			
		||||
																	type="button"
 | 
			
		||||
																	onClick={() =>
 | 
			
		||||
																		copy_to_clipboard(
 | 
			
		||||
																			token.token_key,
 | 
			
		||||
																			`key-${token.id}`,
 | 
			
		||||
																		)
 | 
			
		||||
																	}
 | 
			
		||||
																	className="text-primary-600 hover:text-primary-700 dark:text-primary-400"
 | 
			
		||||
																>
 | 
			
		||||
																	{copy_success[`key-${token.id}`] ? (
 | 
			
		||||
																		<CheckCircle className="h-4 w-4" />
 | 
			
		||||
																	) : (
 | 
			
		||||
																		<Copy className="h-4 w-4" />
 | 
			
		||||
																	)}
 | 
			
		||||
																</button>
 | 
			
		||||
															</div>
 | 
			
		||||
															<p>
 | 
			
		||||
																Usage: {token.hosts_created_today}/
 | 
			
		||||
																{token.max_hosts_per_day} hosts today
 | 
			
		||||
															</p>
 | 
			
		||||
															{token.host_groups && (
 | 
			
		||||
																<p>
 | 
			
		||||
																	Default Group:{" "}
 | 
			
		||||
																	<span
 | 
			
		||||
																		className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
 | 
			
		||||
																		style={{
 | 
			
		||||
																			backgroundColor: `${token.host_groups.color}20`,
 | 
			
		||||
																			color: token.host_groups.color,
 | 
			
		||||
																		}}
 | 
			
		||||
																	>
 | 
			
		||||
																		{token.host_groups.name}
 | 
			
		||||
																	</span>
 | 
			
		||||
																</p>
 | 
			
		||||
															)}
 | 
			
		||||
															{token.allowed_ip_ranges?.length > 0 && (
 | 
			
		||||
																<p>
 | 
			
		||||
																	Allowed IPs:{" "}
 | 
			
		||||
																	{token.allowed_ip_ranges.join(", ")}
 | 
			
		||||
																</p>
 | 
			
		||||
															)}
 | 
			
		||||
															<p>Created: {format_date(token.created_at)}</p>
 | 
			
		||||
															{token.last_used_at && (
 | 
			
		||||
																<p>
 | 
			
		||||
																	Last Used: {format_date(token.last_used_at)}
 | 
			
		||||
																</p>
 | 
			
		||||
															)}
 | 
			
		||||
															{token.expires_at && (
 | 
			
		||||
																<p>
 | 
			
		||||
																	Expires: {format_date(token.expires_at)}
 | 
			
		||||
																	{new Date(token.expires_at) < new Date() && (
 | 
			
		||||
																		<span className="ml-2 text-red-600 dark:text-red-400">
 | 
			
		||||
																			(Expired)
 | 
			
		||||
																		</span>
 | 
			
		||||
																	)}
 | 
			
		||||
																</p>
 | 
			
		||||
															)}
 | 
			
		||||
														</div>
 | 
			
		||||
													</div>
 | 
			
		||||
													<div className="flex items-center gap-2">
 | 
			
		||||
														<button
 | 
			
		||||
															type="button"
 | 
			
		||||
															onClick={() =>
 | 
			
		||||
																toggle_token_active(token.id, token.is_active)
 | 
			
		||||
															}
 | 
			
		||||
															className={`px-3 py-1 text-sm rounded ${
 | 
			
		||||
																token.is_active
 | 
			
		||||
																	? "bg-secondary-100 text-secondary-700 hover:bg-secondary-200 dark:bg-secondary-700 dark:text-secondary-300"
 | 
			
		||||
																	: "bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900 dark:text-green-300"
 | 
			
		||||
															}`}
 | 
			
		||||
														>
 | 
			
		||||
															{token.is_active ? "Disable" : "Enable"}
 | 
			
		||||
														</button>
 | 
			
		||||
														<button
 | 
			
		||||
															type="button"
 | 
			
		||||
															onClick={() =>
 | 
			
		||||
																delete_token(token.id, token.token_name)
 | 
			
		||||
															}
 | 
			
		||||
															className="text-red-600 hover:text-red-800 dark:text-red-400 p-2"
 | 
			
		||||
														>
 | 
			
		||||
															<Trash2 className="h-4 w-4" />
 | 
			
		||||
														</button>
 | 
			
		||||
													</div>
 | 
			
		||||
												</div>
 | 
			
		||||
											</div>
 | 
			
		||||
										))}
 | 
			
		||||
									</div>
 | 
			
		||||
								)}
 | 
			
		||||
 | 
			
		||||
								{/* Documentation Section */}
 | 
			
		||||
								<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6">
 | 
			
		||||
									<h3 className="text-lg font-semibold text-primary-900 dark:text-primary-200 mb-3">
 | 
			
		||||
										How to Use Auto-Enrollment
 | 
			
		||||
									</h3>
 | 
			
		||||
									<ol className="list-decimal list-inside space-y-2 text-sm text-primary-800 dark:text-primary-300">
 | 
			
		||||
										<li>
 | 
			
		||||
											Create a new auto-enrollment token using the button above
 | 
			
		||||
										</li>
 | 
			
		||||
										<li>
 | 
			
		||||
											Copy the one-line installation command shown in the
 | 
			
		||||
											success dialog
 | 
			
		||||
										</li>
 | 
			
		||||
										<li>SSH into your Proxmox host as root</li>
 | 
			
		||||
										<li>
 | 
			
		||||
											Paste and run the command - it will automatically discover
 | 
			
		||||
											and enroll all running LXC containers
 | 
			
		||||
										</li>
 | 
			
		||||
										<li>View enrolled containers in the Hosts page</li>
 | 
			
		||||
									</ol>
 | 
			
		||||
									<div className="mt-4 p-3 bg-primary-100 dark:bg-primary-900/40 rounded border border-primary-200 dark:border-primary-700">
 | 
			
		||||
										<p className="text-xs text-primary-800 dark:text-primary-300">
 | 
			
		||||
											<strong>💡 Tip:</strong> You can run the same command
 | 
			
		||||
											multiple times safely - already enrolled containers will
 | 
			
		||||
											be automatically skipped.
 | 
			
		||||
										</p>
 | 
			
		||||
									</div>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
						<div className="flex-1">
 | 
			
		||||
							<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
 | 
			
		||||
								Integrations Coming Soon
 | 
			
		||||
							</h3>
 | 
			
		||||
							<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
 | 
			
		||||
								We are building integrations for Slack, Discord, email, and
 | 
			
		||||
								webhooks to streamline alerts and workflows.
 | 
			
		||||
							</p>
 | 
			
		||||
							<div className="mt-3">
 | 
			
		||||
								<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
 | 
			
		||||
									In Development
 | 
			
		||||
								</span>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
						)}
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			{/* Create Token Modal */}
 | 
			
		||||
			{show_create_modal && (
 | 
			
		||||
				<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
 | 
			
		||||
					<div className="bg-white dark:bg-secondary-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
 | 
			
		||||
						<div className="p-6">
 | 
			
		||||
							<div className="flex items-center justify-between mb-6">
 | 
			
		||||
								<h2 className="text-xl font-bold text-secondary-900 dark:text-white">
 | 
			
		||||
									Create Auto-Enrollment Token
 | 
			
		||||
								</h2>
 | 
			
		||||
								<button
 | 
			
		||||
									type="button"
 | 
			
		||||
									onClick={() => setShowCreateModal(false)}
 | 
			
		||||
									className="text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-200"
 | 
			
		||||
								>
 | 
			
		||||
									<X className="h-6 w-6" />
 | 
			
		||||
								</button>
 | 
			
		||||
							</div>
 | 
			
		||||
 | 
			
		||||
							<form onSubmit={create_token} className="space-y-4">
 | 
			
		||||
								<label className="block">
 | 
			
		||||
									<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
 | 
			
		||||
										Token Name *
 | 
			
		||||
									</span>
 | 
			
		||||
									<input
 | 
			
		||||
										type="text"
 | 
			
		||||
										required
 | 
			
		||||
										value={form_data.token_name}
 | 
			
		||||
										onChange={(e) =>
 | 
			
		||||
											setFormData({ ...form_data, token_name: e.target.value })
 | 
			
		||||
										}
 | 
			
		||||
										placeholder="e.g., Proxmox Production"
 | 
			
		||||
										className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
 | 
			
		||||
									/>
 | 
			
		||||
								</label>
 | 
			
		||||
 | 
			
		||||
								<label className="block">
 | 
			
		||||
									<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
 | 
			
		||||
										Max Hosts Per Day
 | 
			
		||||
									</span>
 | 
			
		||||
									<input
 | 
			
		||||
										type="number"
 | 
			
		||||
										min="1"
 | 
			
		||||
										max="1000"
 | 
			
		||||
										value={form_data.max_hosts_per_day}
 | 
			
		||||
										onChange={(e) =>
 | 
			
		||||
											setFormData({
 | 
			
		||||
												...form_data,
 | 
			
		||||
												max_hosts_per_day: e.target.value,
 | 
			
		||||
											})
 | 
			
		||||
										}
 | 
			
		||||
										className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
 | 
			
		||||
									/>
 | 
			
		||||
									<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
 | 
			
		||||
										Maximum number of hosts that can be enrolled per day using
 | 
			
		||||
										this token
 | 
			
		||||
									</p>
 | 
			
		||||
								</label>
 | 
			
		||||
 | 
			
		||||
								<label className="block">
 | 
			
		||||
									<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
 | 
			
		||||
										Default Host Group (Optional)
 | 
			
		||||
									</span>
 | 
			
		||||
									<select
 | 
			
		||||
										value={form_data.default_host_group_id}
 | 
			
		||||
										onChange={(e) =>
 | 
			
		||||
											setFormData({
 | 
			
		||||
												...form_data,
 | 
			
		||||
												default_host_group_id: e.target.value,
 | 
			
		||||
											})
 | 
			
		||||
										}
 | 
			
		||||
										className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
 | 
			
		||||
									>
 | 
			
		||||
										<option value="">No default group</option>
 | 
			
		||||
										{host_groups.map((group) => (
 | 
			
		||||
											<option key={group.id} value={group.id}>
 | 
			
		||||
												{group.name}
 | 
			
		||||
											</option>
 | 
			
		||||
										))}
 | 
			
		||||
									</select>
 | 
			
		||||
									<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
 | 
			
		||||
										Auto-enrolled hosts will be assigned to this group
 | 
			
		||||
									</p>
 | 
			
		||||
								</label>
 | 
			
		||||
 | 
			
		||||
								<label className="block">
 | 
			
		||||
									<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
 | 
			
		||||
										Allowed IP Addresses (Optional)
 | 
			
		||||
									</span>
 | 
			
		||||
									<input
 | 
			
		||||
										type="text"
 | 
			
		||||
										value={form_data.allowed_ip_ranges}
 | 
			
		||||
										onChange={(e) =>
 | 
			
		||||
											setFormData({
 | 
			
		||||
												...form_data,
 | 
			
		||||
												allowed_ip_ranges: e.target.value,
 | 
			
		||||
											})
 | 
			
		||||
										}
 | 
			
		||||
										placeholder="e.g., 192.168.1.100, 10.0.0.50"
 | 
			
		||||
										className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
 | 
			
		||||
									/>
 | 
			
		||||
									<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
 | 
			
		||||
										Comma-separated list of IP addresses allowed to use this
 | 
			
		||||
										token
 | 
			
		||||
									</p>
 | 
			
		||||
								</label>
 | 
			
		||||
 | 
			
		||||
								<label className="block">
 | 
			
		||||
									<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
 | 
			
		||||
										Expiration Date (Optional)
 | 
			
		||||
									</span>
 | 
			
		||||
									<input
 | 
			
		||||
										type="datetime-local"
 | 
			
		||||
										value={form_data.expires_at}
 | 
			
		||||
										onChange={(e) =>
 | 
			
		||||
											setFormData({ ...form_data, expires_at: e.target.value })
 | 
			
		||||
										}
 | 
			
		||||
										className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
 | 
			
		||||
									/>
 | 
			
		||||
								</label>
 | 
			
		||||
 | 
			
		||||
								<div className="flex gap-3 pt-4">
 | 
			
		||||
									<button
 | 
			
		||||
										type="submit"
 | 
			
		||||
										className="flex-1 btn-primary py-2 px-4 rounded-md"
 | 
			
		||||
									>
 | 
			
		||||
										Create Token
 | 
			
		||||
									</button>
 | 
			
		||||
									<button
 | 
			
		||||
										type="button"
 | 
			
		||||
										onClick={() => setShowCreateModal(false)}
 | 
			
		||||
										className="flex-1 bg-secondary-100 dark:bg-secondary-700 text-secondary-700 dark:text-secondary-300 py-2 px-4 rounded-md hover:bg-secondary-200 dark:hover:bg-secondary-600"
 | 
			
		||||
									>
 | 
			
		||||
										Cancel
 | 
			
		||||
									</button>
 | 
			
		||||
								</div>
 | 
			
		||||
							</form>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			)}
 | 
			
		||||
 | 
			
		||||
			{/* New Token Display Modal */}
 | 
			
		||||
			{new_token && (
 | 
			
		||||
				<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
 | 
			
		||||
					<div className="bg-white dark:bg-secondary-800 rounded-lg max-w-2xl w-full">
 | 
			
		||||
						<div className="p-6">
 | 
			
		||||
							<div className="flex items-start gap-3 mb-6">
 | 
			
		||||
								<div className="flex-shrink-0">
 | 
			
		||||
									<CheckCircle className="h-8 w-8 text-green-600 dark:text-green-400" />
 | 
			
		||||
								</div>
 | 
			
		||||
								<div>
 | 
			
		||||
									<h2 className="text-xl font-bold text-secondary-900 dark:text-white">
 | 
			
		||||
										Token Created Successfully
 | 
			
		||||
									</h2>
 | 
			
		||||
									<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
 | 
			
		||||
										Save these credentials now - the secret will not be shown
 | 
			
		||||
										again!
 | 
			
		||||
									</p>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
 | 
			
		||||
							<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6">
 | 
			
		||||
								<div className="flex items-start gap-2">
 | 
			
		||||
									<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
 | 
			
		||||
									<p className="text-sm text-yellow-800 dark:text-yellow-200">
 | 
			
		||||
										<strong>Important:</strong> Store the token secret securely.
 | 
			
		||||
										You will not be able to view it again after closing this
 | 
			
		||||
										dialog.
 | 
			
		||||
									</p>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
 | 
			
		||||
							<div className="space-y-4">
 | 
			
		||||
								<div>
 | 
			
		||||
									<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
 | 
			
		||||
										Token Name
 | 
			
		||||
									</div>
 | 
			
		||||
									<div className="flex items-center gap-2">
 | 
			
		||||
										<input
 | 
			
		||||
											type="text"
 | 
			
		||||
											value={new_token.token_name}
 | 
			
		||||
											readOnly
 | 
			
		||||
											className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-sm"
 | 
			
		||||
										/>
 | 
			
		||||
									</div>
 | 
			
		||||
								</div>
 | 
			
		||||
 | 
			
		||||
								<div>
 | 
			
		||||
									<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
 | 
			
		||||
										Token Key
 | 
			
		||||
									</div>
 | 
			
		||||
									<div className="flex items-center gap-2">
 | 
			
		||||
										<input
 | 
			
		||||
											type="text"
 | 
			
		||||
											value={new_token.token_key}
 | 
			
		||||
											readOnly
 | 
			
		||||
											className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-sm"
 | 
			
		||||
										/>
 | 
			
		||||
										<button
 | 
			
		||||
											type="button"
 | 
			
		||||
											onClick={() =>
 | 
			
		||||
												copy_to_clipboard(new_token.token_key, "new-key")
 | 
			
		||||
											}
 | 
			
		||||
											className="btn-primary flex items-center gap-1 px-3 py-2"
 | 
			
		||||
										>
 | 
			
		||||
											{copy_success["new-key"] ? (
 | 
			
		||||
												<>
 | 
			
		||||
													<CheckCircle className="h-4 w-4" />
 | 
			
		||||
													Copied
 | 
			
		||||
												</>
 | 
			
		||||
											) : (
 | 
			
		||||
												<>
 | 
			
		||||
													<Copy className="h-4 w-4" />
 | 
			
		||||
													Copy
 | 
			
		||||
												</>
 | 
			
		||||
											)}
 | 
			
		||||
										</button>
 | 
			
		||||
									</div>
 | 
			
		||||
								</div>
 | 
			
		||||
 | 
			
		||||
								<div>
 | 
			
		||||
									<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
 | 
			
		||||
										Token Secret
 | 
			
		||||
									</div>
 | 
			
		||||
									<div className="flex items-center gap-2">
 | 
			
		||||
										<input
 | 
			
		||||
											type={show_secret ? "text" : "password"}
 | 
			
		||||
											value={new_token.token_secret}
 | 
			
		||||
											readOnly
 | 
			
		||||
											className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-sm"
 | 
			
		||||
										/>
 | 
			
		||||
										<button
 | 
			
		||||
											type="button"
 | 
			
		||||
											onClick={() => setShowSecret(!show_secret)}
 | 
			
		||||
											className="p-2 text-secondary-600 hover:text-secondary-800 dark:text-secondary-400 dark:hover:text-secondary-200"
 | 
			
		||||
										>
 | 
			
		||||
											{show_secret ? (
 | 
			
		||||
												<EyeOff className="h-5 w-5" />
 | 
			
		||||
											) : (
 | 
			
		||||
												<Eye className="h-5 w-5" />
 | 
			
		||||
											)}
 | 
			
		||||
										</button>
 | 
			
		||||
										<button
 | 
			
		||||
											type="button"
 | 
			
		||||
											onClick={() =>
 | 
			
		||||
												copy_to_clipboard(new_token.token_secret, "new-secret")
 | 
			
		||||
											}
 | 
			
		||||
											className="btn-primary flex items-center gap-1 px-3 py-2"
 | 
			
		||||
										>
 | 
			
		||||
											{copy_success["new-secret"] ? (
 | 
			
		||||
												<>
 | 
			
		||||
													<CheckCircle className="h-4 w-4" />
 | 
			
		||||
													Copied
 | 
			
		||||
												</>
 | 
			
		||||
											) : (
 | 
			
		||||
												<>
 | 
			
		||||
													<Copy className="h-4 w-4" />
 | 
			
		||||
													Copy
 | 
			
		||||
												</>
 | 
			
		||||
											)}
 | 
			
		||||
										</button>
 | 
			
		||||
									</div>
 | 
			
		||||
								</div>
 | 
			
		||||
 | 
			
		||||
								<div className="mt-6">
 | 
			
		||||
									<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
 | 
			
		||||
										One-Line Installation Command
 | 
			
		||||
									</div>
 | 
			
		||||
									<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-2">
 | 
			
		||||
										Run this command on your Proxmox host to download and
 | 
			
		||||
										execute the enrollment script:
 | 
			
		||||
									</p>
 | 
			
		||||
 | 
			
		||||
									{/* Force Install Toggle */}
 | 
			
		||||
									<div className="mb-3">
 | 
			
		||||
										<label className="flex items-center gap-2 text-sm">
 | 
			
		||||
											<input
 | 
			
		||||
												type="checkbox"
 | 
			
		||||
												checked={force_proxmox_install}
 | 
			
		||||
												onChange={(e) =>
 | 
			
		||||
													setForceProxmoxInstall(e.target.checked)
 | 
			
		||||
												}
 | 
			
		||||
												className="rounded border-secondary-300 dark:border-secondary-600 text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-400 dark:bg-secondary-700"
 | 
			
		||||
											/>
 | 
			
		||||
											<span className="text-secondary-800 dark:text-secondary-200">
 | 
			
		||||
												Force install (bypass broken packages in containers)
 | 
			
		||||
											</span>
 | 
			
		||||
										</label>
 | 
			
		||||
										<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-1">
 | 
			
		||||
											Enable this if your LXC containers have broken packages
 | 
			
		||||
											(CloudPanel, WHM, etc.) that block apt-get operations
 | 
			
		||||
										</p>
 | 
			
		||||
									</div>
 | 
			
		||||
 | 
			
		||||
									<div className="flex items-center gap-2">
 | 
			
		||||
										<input
 | 
			
		||||
											type="text"
 | 
			
		||||
											value={`curl -s "${getProxmoxUrl()}" | bash`}
 | 
			
		||||
											readOnly
 | 
			
		||||
											className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
 | 
			
		||||
										/>
 | 
			
		||||
										<button
 | 
			
		||||
											type="button"
 | 
			
		||||
											onClick={() =>
 | 
			
		||||
												copy_to_clipboard(
 | 
			
		||||
													`curl -s "${getProxmoxUrl()}" | bash`,
 | 
			
		||||
													"curl-command",
 | 
			
		||||
												)
 | 
			
		||||
											}
 | 
			
		||||
											className="btn-primary flex items-center gap-1 px-3 py-2 whitespace-nowrap"
 | 
			
		||||
										>
 | 
			
		||||
											{copy_success["curl-command"] ? (
 | 
			
		||||
												<>
 | 
			
		||||
													<CheckCircle className="h-4 w-4" />
 | 
			
		||||
													Copied
 | 
			
		||||
												</>
 | 
			
		||||
											) : (
 | 
			
		||||
												<>
 | 
			
		||||
													<Copy className="h-4 w-4" />
 | 
			
		||||
													Copy
 | 
			
		||||
												</>
 | 
			
		||||
											)}
 | 
			
		||||
										</button>
 | 
			
		||||
									</div>
 | 
			
		||||
									<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-2">
 | 
			
		||||
										💡 This command will automatically discover and enroll all
 | 
			
		||||
										running LXC containers.
 | 
			
		||||
									</p>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
 | 
			
		||||
							<div className="flex gap-3 pt-6">
 | 
			
		||||
								<button
 | 
			
		||||
									type="button"
 | 
			
		||||
									onClick={() => {
 | 
			
		||||
										setNewToken(null);
 | 
			
		||||
										setShowSecret(false);
 | 
			
		||||
									}}
 | 
			
		||||
									className="flex-1 btn-primary py-2 px-4 rounded-md"
 | 
			
		||||
								>
 | 
			
		||||
									I've Saved the Credentials
 | 
			
		||||
								</button>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			)}
 | 
			
		||||
		</SettingsLayout>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,8 @@
 | 
			
		||||
import { Code, Server } from "lucide-react";
 | 
			
		||||
import { Code, Image, Server } from "lucide-react";
 | 
			
		||||
import { useEffect, useState } from "react";
 | 
			
		||||
import { useLocation, useNavigate } from "react-router-dom";
 | 
			
		||||
import SettingsLayout from "../../components/SettingsLayout";
 | 
			
		||||
import BrandingTab from "../../components/settings/BrandingTab";
 | 
			
		||||
import ProtocolUrlTab from "../../components/settings/ProtocolUrlTab";
 | 
			
		||||
import VersionUpdateTab from "../../components/settings/VersionUpdateTab";
 | 
			
		||||
 | 
			
		||||
@@ -12,6 +13,7 @@ const SettingsServerConfig = () => {
 | 
			
		||||
		// Set initial tab based on current route
 | 
			
		||||
		if (location.pathname === "/settings/server-version") return "version";
 | 
			
		||||
		if (location.pathname === "/settings/server-url") return "protocol";
 | 
			
		||||
		if (location.pathname === "/settings/branding") return "branding";
 | 
			
		||||
		if (location.pathname === "/settings/server-config/version")
 | 
			
		||||
			return "version";
 | 
			
		||||
		return "protocol";
 | 
			
		||||
@@ -23,6 +25,8 @@ const SettingsServerConfig = () => {
 | 
			
		||||
			setActiveTab("version");
 | 
			
		||||
		} else if (location.pathname === "/settings/server-url") {
 | 
			
		||||
			setActiveTab("protocol");
 | 
			
		||||
		} else if (location.pathname === "/settings/branding") {
 | 
			
		||||
			setActiveTab("branding");
 | 
			
		||||
		} else if (location.pathname === "/settings/server-config/version") {
 | 
			
		||||
			setActiveTab("version");
 | 
			
		||||
		} else if (location.pathname === "/settings/server-config") {
 | 
			
		||||
@@ -37,6 +41,12 @@ const SettingsServerConfig = () => {
 | 
			
		||||
			icon: Server,
 | 
			
		||||
			href: "/settings/server-url",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			id: "branding",
 | 
			
		||||
			name: "Branding",
 | 
			
		||||
			icon: Image,
 | 
			
		||||
			href: "/settings/branding",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			id: "version",
 | 
			
		||||
			name: "Server Version",
 | 
			
		||||
@@ -49,6 +59,8 @@ const SettingsServerConfig = () => {
 | 
			
		||||
		switch (activeTab) {
 | 
			
		||||
			case "protocol":
 | 
			
		||||
				return <ProtocolUrlTab />;
 | 
			
		||||
			case "branding":
 | 
			
		||||
				return <BrandingTab />;
 | 
			
		||||
			case "version":
 | 
			
		||||
				return <VersionUpdateTab />;
 | 
			
		||||
			default:
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
@@ -259,4 +260,9 @@ export const formatRelativeTime = (date) => {
 | 
			
		||||
	return `${seconds} second${seconds > 1 ? "s" : ""} ago`;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Search API
 | 
			
		||||
export const searchAPI = {
 | 
			
		||||
	global: (query) => api.get("/search", { params: { q: query } }),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default api;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										117
									
								
								setup.sh
									
									
									
									
									
								
							
							
						
						
									
										117
									
								
								setup.sh
									
									
									
									
									
								
							@@ -35,7 +35,7 @@ NC='\033[0m' # No Color
 | 
			
		||||
 | 
			
		||||
# Global variables
 | 
			
		||||
SCRIPT_VERSION="self-hosting-install.sh v1.2.7-selfhost-2025-01-20-1"
 | 
			
		||||
DEFAULT_GITHUB_REPO="https://github.com/9technologygroup/patchmon.net.git"
 | 
			
		||||
DEFAULT_GITHUB_REPO="https://github.com/PatchMon/PatchMon.git"
 | 
			
		||||
FQDN=""
 | 
			
		||||
CUSTOM_FQDN=""
 | 
			
		||||
EMAIL=""
 | 
			
		||||
@@ -254,7 +254,7 @@ check_prerequisites() {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
select_branch() {
 | 
			
		||||
    print_info "Fetching available branches from GitHub repository..."
 | 
			
		||||
    print_info "Fetching available releases from GitHub repository..."
 | 
			
		||||
    
 | 
			
		||||
    # Create temporary directory for git operations
 | 
			
		||||
    TEMP_DIR="/tmp/patchmon_branches_$$"
 | 
			
		||||
@@ -263,84 +263,88 @@ select_branch() {
 | 
			
		||||
    
 | 
			
		||||
    # Try to clone the repository normally
 | 
			
		||||
    if git clone "$DEFAULT_GITHUB_REPO" . 2>/dev/null; then
 | 
			
		||||
        # Get list of remote branches and trim whitespace
 | 
			
		||||
        branches=$(git branch -r | grep -v HEAD | sed 's/origin\///' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//' | sort -u)
 | 
			
		||||
        # Get list of tags sorted by version (semantic versioning)
 | 
			
		||||
        # Using git tag with version sorting
 | 
			
		||||
        tags=$(git tag -l --sort=-v:refname 2>/dev/null | head -3)
 | 
			
		||||
        
 | 
			
		||||
        if [ -n "$branches" ]; then
 | 
			
		||||
            print_info "Available branches with details:"
 | 
			
		||||
        if [ -n "$tags" ]; then
 | 
			
		||||
            print_info "Available releases and branches:"
 | 
			
		||||
            echo ""
 | 
			
		||||
            
 | 
			
		||||
            # Get branch information
 | 
			
		||||
            branch_count=1
 | 
			
		||||
            while IFS= read -r branch; do
 | 
			
		||||
                if [ -n "$branch" ]; then
 | 
			
		||||
                    # Get last commit date for this branch
 | 
			
		||||
                    last_commit=$(git log -1 --format="%ci" "origin/$branch" 2>/dev/null || echo "Unknown")
 | 
			
		||||
                    
 | 
			
		||||
                    # Get release tag associated with this branch (if any)
 | 
			
		||||
                    release_tag=$(git describe --tags --exact-match "origin/$branch" 2>/dev/null || echo "")
 | 
			
		||||
            # Display last 3 release tags
 | 
			
		||||
            option_count=1
 | 
			
		||||
            declare -A options_map
 | 
			
		||||
            
 | 
			
		||||
            while IFS= read -r tag; do
 | 
			
		||||
                if [ -n "$tag" ]; then
 | 
			
		||||
                    # Get tag date and commit info
 | 
			
		||||
                    tag_date=$(git log -1 --format="%ci" "$tag" 2>/dev/null || echo "Unknown")
 | 
			
		||||
                    
 | 
			
		||||
                    # Format the date
 | 
			
		||||
                    if [ "$last_commit" != "Unknown" ]; then
 | 
			
		||||
                        formatted_date=$(date -d "$last_commit" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$last_commit")
 | 
			
		||||
                    if [ "$tag_date" != "Unknown" ]; then
 | 
			
		||||
                        formatted_date=$(date -d "$tag_date" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$tag_date")
 | 
			
		||||
                    else
 | 
			
		||||
                        formatted_date="Unknown"
 | 
			
		||||
                    fi
 | 
			
		||||
                    
 | 
			
		||||
                    # Display branch info
 | 
			
		||||
                    printf "%2d. %-20s" "$branch_count" "$branch"
 | 
			
		||||
                    printf " (Last commit: %s)" "$formatted_date"
 | 
			
		||||
                    
 | 
			
		||||
                    if [ -n "$release_tag" ]; then
 | 
			
		||||
                        printf " [Release: %s]" "$release_tag"
 | 
			
		||||
                    # Mark the first one as latest
 | 
			
		||||
                    if [ $option_count -eq 1 ]; then
 | 
			
		||||
                        printf "%2d. %-20s (Latest Release - %s)\n" "$option_count" "$tag" "$formatted_date"
 | 
			
		||||
                    else
 | 
			
		||||
                        printf "%2d. %-20s (Release - %s)\n" "$option_count" "$tag" "$formatted_date"
 | 
			
		||||
                    fi
 | 
			
		||||
                    
 | 
			
		||||
                    echo ""
 | 
			
		||||
                    branch_count=$((branch_count + 1))
 | 
			
		||||
                    # Store the tag for later selection
 | 
			
		||||
                    options_map[$option_count]="$tag"
 | 
			
		||||
                    option_count=$((option_count + 1))
 | 
			
		||||
                fi
 | 
			
		||||
            done <<< "$branches"
 | 
			
		||||
            done <<< "$tags"
 | 
			
		||||
            
 | 
			
		||||
            # Add main branch as an option
 | 
			
		||||
            main_commit=$(git log -1 --format="%ci" "origin/main" 2>/dev/null || echo "Unknown")
 | 
			
		||||
            if [ "$main_commit" != "Unknown" ]; then
 | 
			
		||||
                formatted_main_date=$(date -d "$main_commit" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$main_commit")
 | 
			
		||||
            else
 | 
			
		||||
                formatted_main_date="Unknown"
 | 
			
		||||
            fi
 | 
			
		||||
            printf "%2d. %-20s (Development Branch - %s)\n" "$option_count" "main" "$formatted_main_date"
 | 
			
		||||
            options_map[$option_count]="main"
 | 
			
		||||
            
 | 
			
		||||
            echo ""
 | 
			
		||||
            
 | 
			
		||||
            # Determine default selection: prefer 'main' if present
 | 
			
		||||
            main_index=$(echo "$branches" | nl -w1 -s':' | awk -F':' '$2=="main"{print $1}' | head -1)
 | 
			
		||||
            if [ -z "$main_index" ]; then
 | 
			
		||||
                main_index=1
 | 
			
		||||
            fi
 | 
			
		||||
            # Default to option 1 (latest release tag)
 | 
			
		||||
            default_option=1
 | 
			
		||||
            
 | 
			
		||||
            while true; do
 | 
			
		||||
                read_input "Select branch number" BRANCH_NUMBER "$main_index"
 | 
			
		||||
                read_input "Select version/branch number" SELECTION_NUMBER "$default_option"
 | 
			
		||||
                
 | 
			
		||||
                if [[ "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
 | 
			
		||||
                    selected_branch=$(echo "$branches" | sed -n "${BRANCH_NUMBER}p" | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
 | 
			
		||||
                    if [ -n "$selected_branch" ]; then
 | 
			
		||||
                        DEPLOYMENT_BRANCH="$selected_branch"
 | 
			
		||||
                if [[ "$SELECTION_NUMBER" =~ ^[0-9]+$ ]]; then
 | 
			
		||||
                    selected_option="${options_map[$SELECTION_NUMBER]}"
 | 
			
		||||
                    if [ -n "$selected_option" ]; then
 | 
			
		||||
                        DEPLOYMENT_BRANCH="$selected_option"
 | 
			
		||||
                        
 | 
			
		||||
                        # Show additional info for selected branch
 | 
			
		||||
                        last_commit=$(git log -1 --format="%ci" "origin/$selected_branch" 2>/dev/null || echo "Unknown")
 | 
			
		||||
                        release_tag=$(git describe --tags --exact-match "origin/$selected_branch" 2>/dev/null || echo "")
 | 
			
		||||
                        
 | 
			
		||||
                        if [ "$last_commit" != "Unknown" ]; then
 | 
			
		||||
                            formatted_date=$(date -d "$last_commit" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$last_commit")
 | 
			
		||||
                        # Show confirmation
 | 
			
		||||
                        if [ "$selected_option" = "main" ]; then
 | 
			
		||||
                            print_status "Selected branch: main (latest development code)"
 | 
			
		||||
                            print_info "Last commit: $formatted_main_date"
 | 
			
		||||
                        else
 | 
			
		||||
                            formatted_date="Unknown"
 | 
			
		||||
                        fi
 | 
			
		||||
                        
 | 
			
		||||
                        print_status "Selected branch: $DEPLOYMENT_BRANCH"
 | 
			
		||||
                        print_info "Last commit: $formatted_date"
 | 
			
		||||
                        if [ -n "$release_tag" ]; then
 | 
			
		||||
                            print_info "Release tag: $release_tag"
 | 
			
		||||
                            print_status "Selected release: $selected_option"
 | 
			
		||||
                            tag_date=$(git log -1 --format="%ci" "$selected_option" 2>/dev/null || echo "Unknown")
 | 
			
		||||
                            if [ "$tag_date" != "Unknown" ]; then
 | 
			
		||||
                                formatted_date=$(date -d "$tag_date" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$tag_date")
 | 
			
		||||
                                print_info "Release date: $formatted_date"
 | 
			
		||||
                            fi
 | 
			
		||||
                        fi
 | 
			
		||||
                        break
 | 
			
		||||
                    else
 | 
			
		||||
                        print_error "Invalid branch number. Please try again."
 | 
			
		||||
                        print_error "Invalid selection number. Please try again."
 | 
			
		||||
                    fi
 | 
			
		||||
                else
 | 
			
		||||
                    print_error "Please enter a valid number."
 | 
			
		||||
                fi
 | 
			
		||||
            done
 | 
			
		||||
        else
 | 
			
		||||
            print_warning "No branches found, using default: main"
 | 
			
		||||
            print_warning "No release tags found, using default: main"
 | 
			
		||||
            DEPLOYMENT_BRANCH="main"
 | 
			
		||||
        fi
 | 
			
		||||
    else
 | 
			
		||||
@@ -789,9 +793,13 @@ create_env_files() {
 | 
			
		||||
    cat > backend/.env << EOF
 | 
			
		||||
# Database Configuration
 | 
			
		||||
DATABASE_URL="postgresql://$DB_USER:$DB_PASS@localhost:5432/$DB_NAME"
 | 
			
		||||
PM_DB_CONN_MAX_ATTEMPTS=30
 | 
			
		||||
PM_DB_CONN_WAIT_INTERVAL=2
 | 
			
		||||
 | 
			
		||||
# JWT Configuration
 | 
			
		||||
JWT_SECRET="$JWT_SECRET"
 | 
			
		||||
JWT_EXPIRES_IN=1h
 | 
			
		||||
JWT_REFRESH_EXPIRES_IN=7d
 | 
			
		||||
 | 
			
		||||
# Server Configuration
 | 
			
		||||
PORT=$BACKEND_PORT
 | 
			
		||||
@@ -803,6 +811,12 @@ API_VERSION=v1
 | 
			
		||||
# CORS Configuration
 | 
			
		||||
CORS_ORIGIN="$SERVER_PROTOCOL_SEL://$FQDN"
 | 
			
		||||
 | 
			
		||||
# Session Configuration
 | 
			
		||||
SESSION_INACTIVITY_TIMEOUT_MINUTES=30
 | 
			
		||||
 | 
			
		||||
# User Configuration
 | 
			
		||||
DEFAULT_USER_ROLE=user
 | 
			
		||||
 | 
			
		||||
# Rate Limiting (times in milliseconds)
 | 
			
		||||
RATE_LIMIT_WINDOW_MS=900000
 | 
			
		||||
RATE_LIMIT_MAX=5000
 | 
			
		||||
@@ -813,6 +827,7 @@ AGENT_RATE_LIMIT_MAX=1000
 | 
			
		||||
 | 
			
		||||
# Logging
 | 
			
		||||
LOG_LEVEL=info
 | 
			
		||||
ENABLE_LOGGING=true
 | 
			
		||||
EOF
 | 
			
		||||
 | 
			
		||||
    # Frontend .env
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user