mirror of
				https://github.com/9technologygroup/patchmon.net.git
				synced 2025-11-04 05:53:27 +00:00 
			
		
		
		
	Compare commits
	
		
			60 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					6ebcdd57d5 | ||
| 
						 | 
					a3d0dfd665 | ||
| 
						 | 
					d99ded6d65 | ||
| 
						 | 
					1ea96b6172 | ||
| 
						 | 
					1e5ee66825 | ||
| 
						 | 
					88130797e4 | ||
| 
						 | 
					566c415471 | ||
| 
						 | 
					cfc91243eb | ||
| 
						 | 
					84cf31869b | ||
| 
						 | 
					18c9d241eb | ||
| 
						 | 
					86b5da3ea0 | ||
| 
						 | 
					c9b5ee63d8 | ||
| 
						 | 
					ac4415e1dc | ||
| 
						 | 
					3737a5a935 | ||
| 
						 | 
					bcce48948a | ||
| 
						 | 
					5e4c628110 | ||
| 
						 | 
					a8668ee3f3 | ||
| 
						 | 
					5487206384 | ||
| 
						 | 
					daa31973f9 | ||
| 
						 | 
					561c78fb08 | ||
| 
						 | 
					6d3f2d94ba | ||
| 
						 | 
					93534ebe52 | ||
| 
						 | 
					5cf2811bfd | ||
| 
						 | 
					8fd91eae1a | ||
| 
						 | 
					da8c661d20 | ||
| 
						 | 
					2bf639e315 | ||
| 
						 | 
					c02ac4bd6f | ||
| 
						 | 
					4e0eaf7323 | ||
| 
						 | 
					ef9ef58bcb | ||
| 
						 | 
					29afe3da1f | ||
| 
						 | 
					a861e4f9eb | ||
| 
						 | 
					12ef6fd8e1 | ||
| 
						 | 
					ba9de097dc | ||
| 
						 | 
					8103581d17 | ||
| 
						 | 
					cdb24520d8 | ||
| 
						 | 
					831adf3038 | ||
| 
						 | 
					2a1eed1354 | ||
| 
						 | 
					7819d4512e | ||
| 
						 | 
					a305fe23d3 | ||
| 
						 | 
					2b36e88d85 | ||
| 
						 | 
					6624ec002d | ||
| 
						 | 
					840779844a | ||
| 
						 | 
					f91d3324ba | ||
| 
						 | 
					8c60b5277e | ||
| 
						 | 
					2ac756af84 | ||
| 
						 | 
					e227004d6b | ||
| 
						 | 
					d379473568 | ||
| 
						 | 
					2edc773adf | ||
| 
						 | 
					2db839556c | ||
| 
						 | 
					aab6fc244e | ||
| 
						 | 
					811f5b5885 | ||
| 
						 | 
					b43c9e94fd | ||
| 
						 | 
					2e2a554aa3 | ||
| 
						 | 
					eabcfd370c | ||
| 
						 | 
					55cb07b3c8 | ||
| 
						 | 
					0e049ec3d5 | ||
| 
						 | 
					a2464fac5c | ||
| 
						 | 
					63817b450f | ||
| 
						 | 
					dcaffe2805 | ||
| 
						 | 
					6eb6ea3fd6 | 
							
								
								
									
										4
									
								
								.github/workflows/app_build.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/app_build.yml
									
									
									
									
										vendored
									
									
								
							@@ -3,7 +3,9 @@ on:
 | 
			
		||||
  push: 
 | 
			
		||||
    branches:
 | 
			
		||||
      - main
 | 
			
		||||
      - dev
 | 
			
		||||
    paths-ignore:
 | 
			
		||||
      - 'docker/**'
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  deploy:
 | 
			
		||||
    runs-on: self-hosted
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										4
									
								
								.github/workflows/code_quality.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/code_quality.yml
									
									
									
									
										vendored
									
									
								
							@@ -2,7 +2,11 @@ name: Code quality
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    paths-ignore:
 | 
			
		||||
      - 'docker/**'
 | 
			
		||||
  pull_request:
 | 
			
		||||
    paths-ignore:
 | 
			
		||||
      - 'docker/**'
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  check:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										17
									
								
								.github/workflows/docker.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										17
									
								
								.github/workflows/docker.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,13 +1,14 @@
 | 
			
		||||
name: Build and Push Docker Images
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches:
 | 
			
		||||
      - main
 | 
			
		||||
    tags:
 | 
			
		||||
      - 'v*'
 | 
			
		||||
  pull_request:
 | 
			
		||||
    branches:
 | 
			
		||||
      - main
 | 
			
		||||
      - dev
 | 
			
		||||
  release:
 | 
			
		||||
    types:
 | 
			
		||||
      - published
 | 
			
		||||
  workflow_dispatch:
 | 
			
		||||
    inputs:
 | 
			
		||||
      push:
 | 
			
		||||
@@ -56,7 +57,7 @@ jobs:
 | 
			
		||||
            type=semver,pattern={{version}}
 | 
			
		||||
            type=semver,pattern={{major}}.{{minor}}
 | 
			
		||||
            type=semver,pattern={{major}}
 | 
			
		||||
            type=raw,value=latest,enable={{is_default_branch}}
 | 
			
		||||
            type=edge,branch=main
 | 
			
		||||
 | 
			
		||||
      - name: Build and push ${{ matrix.image }} image
 | 
			
		||||
        uses: docker/build-push-action@v6
 | 
			
		||||
@@ -64,7 +65,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 }}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -154,4 +154,4 @@ setup-installer-site.sh
 | 
			
		||||
install-server.*
 | 
			
		||||
notify-clients-upgrade.sh
 | 
			
		||||
debug-agent.sh
 | 
			
		||||
docker/compose_dev_data
 | 
			
		||||
docker/compose_dev_*
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										17
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								README.md
									
									
									
									
									
								
							@@ -43,7 +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))
 | 
			
		||||
- Proxmox LXC Auto-Enrollment - Automatically discover and enroll LXC containers from Proxmox hosts
 | 
			
		||||
 | 
			
		||||
### Security
 | 
			
		||||
- Rate limiting for general, auth, and agent endpoints
 | 
			
		||||
@@ -85,11 +85,16 @@ apt-get upgrade -y
 | 
			
		||||
apt install curl -y
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### Script
 | 
			
		||||
#### Install Script
 | 
			
		||||
```bash
 | 
			
		||||
curl -fsSL -o setup.sh https://raw.githubusercontent.com/PatchMon/PatchMon/refs/heads/main/setup.sh && chmod +x setup.sh && bash setup.sh
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### Update Script (--update flag)
 | 
			
		||||
```bash
 | 
			
		||||
curl -fsSL -o setup.sh https://raw.githubusercontent.com/PatchMon/PatchMon/refs/heads/main/setup.sh && chmod +x setup.sh && bash setup.sh --update
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### Minimum specs for building : #####
 | 
			
		||||
CPU : 2 vCPU
 | 
			
		||||
RAM : 2GB
 | 
			
		||||
@@ -113,6 +118,14 @@ After installation:
 | 
			
		||||
- Visit `http(s)://<your-domain>` and complete first-time admin setup
 | 
			
		||||
- See all useful info in `deployment-info.txt`
 | 
			
		||||
 | 
			
		||||
## Forcing updates after host package changes
 | 
			
		||||
Should you perform a manual package update on your host and wish to see the results reflected in PatchMon quicker than the usual scheduled update, you can trigger the process manually by running:
 | 
			
		||||
```bash
 | 
			
		||||
/usr/local/bin/patchmon-agent.sh update
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
This will send the results immediately to PatchMon.
 | 
			
		||||
 | 
			
		||||
## Communication Model
 | 
			
		||||
 | 
			
		||||
- Outbound-only agents: servers initiate communication to PatchMon
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,12 @@
 | 
			
		||||
#!/bin/bash
 | 
			
		||||
 | 
			
		||||
# PatchMon Agent Script v1.2.7
 | 
			
		||||
# PatchMon Agent Script v1.2.8
 | 
			
		||||
# This script sends package update information to the PatchMon server using API credentials
 | 
			
		||||
 | 
			
		||||
# Configuration
 | 
			
		||||
PATCHMON_SERVER="${PATCHMON_SERVER:-http://localhost:3001}"
 | 
			
		||||
API_VERSION="v1"
 | 
			
		||||
AGENT_VERSION="1.2.7"
 | 
			
		||||
AGENT_VERSION="1.2.8"
 | 
			
		||||
CONFIG_FILE="/etc/patchmon/agent.conf"
 | 
			
		||||
CREDENTIALS_FILE="/etc/patchmon/credentials"
 | 
			
		||||
LOG_FILE="/var/log/patchmon-agent.log"
 | 
			
		||||
@@ -231,9 +231,14 @@ detect_os() {
 | 
			
		||||
            "opensuse"|"opensuse-leap"|"opensuse-tumbleweed")
 | 
			
		||||
                OS_TYPE="suse"
 | 
			
		||||
                ;;
 | 
			
		||||
            "rocky"|"almalinux")
 | 
			
		||||
            "almalinux")
 | 
			
		||||
                OS_TYPE="rhel"
 | 
			
		||||
                ;;
 | 
			
		||||
            "ol")
 | 
			
		||||
                # Keep Oracle Linux as 'ol' for proper frontend identification
 | 
			
		||||
                OS_TYPE="ol"
 | 
			
		||||
                ;;
 | 
			
		||||
            # Rocky Linux keeps its own identity for proper frontend display
 | 
			
		||||
        esac
 | 
			
		||||
        
 | 
			
		||||
    elif [[ -f /etc/redhat-release ]]; then
 | 
			
		||||
@@ -261,7 +266,7 @@ get_repository_info() {
 | 
			
		||||
        "ubuntu"|"debian")
 | 
			
		||||
            get_apt_repositories repos_json first
 | 
			
		||||
            ;;
 | 
			
		||||
        "centos"|"rhel"|"fedora")
 | 
			
		||||
        "centos"|"rhel"|"fedora"|"ol"|"rocky")
 | 
			
		||||
            get_yum_repositories repos_json first
 | 
			
		||||
            ;;
 | 
			
		||||
        *)
 | 
			
		||||
@@ -569,14 +574,118 @@ get_yum_repositories() {
 | 
			
		||||
    local -n first_ref=$2
 | 
			
		||||
    
 | 
			
		||||
    # Parse yum/dnf repository configuration
 | 
			
		||||
    local repo_info=""
 | 
			
		||||
    if command -v dnf >/dev/null 2>&1; then
 | 
			
		||||
        local repo_info=$(dnf repolist all --verbose 2>/dev/null | grep -E "^Repo-id|^Repo-baseurl|^Repo-name|^Repo-status")
 | 
			
		||||
        repo_info=$(dnf repolist all --verbose 2>/dev/null | grep -E "^Repo-id|^Repo-baseurl|^Repo-mirrors|^Repo-name|^Repo-status")
 | 
			
		||||
    elif command -v yum >/dev/null 2>&1; then
 | 
			
		||||
        local repo_info=$(yum repolist all -v 2>/dev/null | grep -E "^Repo-id|^Repo-baseurl|^Repo-name|^Repo-status")
 | 
			
		||||
        repo_info=$(yum repolist all -v 2>/dev/null | grep -E "^Repo-id|^Repo-baseurl|^Repo-mirrors|^Repo-name|^Repo-status")
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # This is a simplified implementation - would need more work for full YUM support
 | 
			
		||||
    # For now, return empty for non-APT systems
 | 
			
		||||
    if [[ -z "$repo_info" ]]; then
 | 
			
		||||
        return
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Parse repository information
 | 
			
		||||
    local current_repo=""
 | 
			
		||||
    local repo_id=""
 | 
			
		||||
    local repo_name=""
 | 
			
		||||
    local repo_url=""
 | 
			
		||||
    local repo_mirrors=""
 | 
			
		||||
    local repo_status=""
 | 
			
		||||
    
 | 
			
		||||
    while IFS= read -r line; do
 | 
			
		||||
        if [[ "$line" =~ ^Repo-id[[:space:]]+:[[:space:]]+(.+)$ ]]; then
 | 
			
		||||
            # Process previous repository if we have one
 | 
			
		||||
            if [[ -n "$current_repo" ]]; then
 | 
			
		||||
                process_yum_repo repos_ref first_ref "$repo_id" "$repo_name" "$repo_url" "$repo_mirrors" "$repo_status"
 | 
			
		||||
            fi
 | 
			
		||||
            
 | 
			
		||||
            # Start new repository
 | 
			
		||||
            repo_id="${BASH_REMATCH[1]}"
 | 
			
		||||
            repo_name="$repo_id"
 | 
			
		||||
            repo_url=""
 | 
			
		||||
            repo_mirrors=""
 | 
			
		||||
            repo_status=""
 | 
			
		||||
            current_repo="$repo_id"
 | 
			
		||||
            
 | 
			
		||||
        elif [[ "$line" =~ ^Repo-name[[:space:]]+:[[:space:]]+(.+)$ ]]; then
 | 
			
		||||
            repo_name="${BASH_REMATCH[1]}"
 | 
			
		||||
            
 | 
			
		||||
        elif [[ "$line" =~ ^Repo-baseurl[[:space:]]+:[[:space:]]+(.+)$ ]]; then
 | 
			
		||||
            repo_url="${BASH_REMATCH[1]}"
 | 
			
		||||
            
 | 
			
		||||
        elif [[ "$line" =~ ^Repo-mirrors[[:space:]]+:[[:space:]]+(.+)$ ]]; then
 | 
			
		||||
            repo_mirrors="${BASH_REMATCH[1]}"
 | 
			
		||||
            
 | 
			
		||||
        elif [[ "$line" =~ ^Repo-status[[:space:]]+:[[:space:]]+(.+)$ ]]; then
 | 
			
		||||
            repo_status="${BASH_REMATCH[1]}"
 | 
			
		||||
        fi
 | 
			
		||||
    done <<< "$repo_info"
 | 
			
		||||
    
 | 
			
		||||
    # Process the last repository
 | 
			
		||||
    if [[ -n "$current_repo" ]]; then
 | 
			
		||||
        process_yum_repo repos_ref first_ref "$repo_id" "$repo_name" "$repo_url" "$repo_mirrors" "$repo_status"
 | 
			
		||||
    fi
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Process a single YUM repository and add it to the JSON
 | 
			
		||||
process_yum_repo() {
 | 
			
		||||
    local -n _repos_ref=$1
 | 
			
		||||
    local -n _first_ref=$2
 | 
			
		||||
    local repo_id="$3"
 | 
			
		||||
    local repo_name="$4"
 | 
			
		||||
    local repo_url="$5"
 | 
			
		||||
    local repo_mirrors="$6"
 | 
			
		||||
    local repo_status="$7"
 | 
			
		||||
    
 | 
			
		||||
    # Skip if we don't have essential info
 | 
			
		||||
    if [[ -z "$repo_id" ]]; then
 | 
			
		||||
        return
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Determine if repository is enabled
 | 
			
		||||
    local is_enabled=false
 | 
			
		||||
    if [[ "$repo_status" == "enabled" ]]; then
 | 
			
		||||
        is_enabled=true
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Use baseurl if available, otherwise use mirrors URL
 | 
			
		||||
    local final_url=""
 | 
			
		||||
    if [[ -n "$repo_url" ]]; then
 | 
			
		||||
        # Extract first URL if multiple are listed
 | 
			
		||||
        final_url=$(echo "$repo_url" | head -n 1 | awk '{print $1}')
 | 
			
		||||
    elif [[ -n "$repo_mirrors" ]]; then
 | 
			
		||||
        final_url="$repo_mirrors"
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Skip if we don't have any URL
 | 
			
		||||
    if [[ -z "$final_url" ]]; then
 | 
			
		||||
        return
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Determine if repository uses HTTPS
 | 
			
		||||
    local is_secure=false
 | 
			
		||||
    if [[ "$final_url" =~ ^https:// ]]; then
 | 
			
		||||
        is_secure=true
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Generate repository name if not provided
 | 
			
		||||
    if [[ -z "$repo_name" ]]; then
 | 
			
		||||
        repo_name="$repo_id"
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Clean up repository name and URL - escape quotes and backslashes
 | 
			
		||||
    repo_name=$(echo "$repo_name" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g')
 | 
			
		||||
    final_url=$(echo "$final_url" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g')
 | 
			
		||||
    
 | 
			
		||||
    # Add to JSON
 | 
			
		||||
    if [[ "$_first_ref" == true ]]; then
 | 
			
		||||
        _first_ref=false
 | 
			
		||||
    else
 | 
			
		||||
        _repos_ref+=","
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    _repos_ref+="{\"name\":\"$repo_name\",\"url\":\"$final_url\",\"distribution\":\"$OS_VERSION\",\"components\":\"main\",\"repoType\":\"rpm\",\"isEnabled\":$is_enabled,\"isSecure\":$is_secure}"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Get package information based on OS
 | 
			
		||||
@@ -588,11 +697,11 @@ get_package_info() {
 | 
			
		||||
        "ubuntu"|"debian")
 | 
			
		||||
            get_apt_packages packages_json first
 | 
			
		||||
            ;;
 | 
			
		||||
        "centos"|"rhel"|"fedora")
 | 
			
		||||
        "centos"|"rhel"|"fedora"|"ol"|"rocky")
 | 
			
		||||
            get_yum_packages packages_json first
 | 
			
		||||
            ;;
 | 
			
		||||
        *)
 | 
			
		||||
            error "Unsupported OS type: $OS_TYPE"
 | 
			
		||||
            warning "Unsupported OS type: $OS_TYPE - returning empty package list"
 | 
			
		||||
            ;;
 | 
			
		||||
    esac
 | 
			
		||||
    
 | 
			
		||||
@@ -605,8 +714,24 @@ get_apt_packages() {
 | 
			
		||||
    local -n packages_ref=$1
 | 
			
		||||
    local -n first_ref=$2
 | 
			
		||||
    
 | 
			
		||||
    # Update package lists (use apt-get for older distros; quieter output)
 | 
			
		||||
    apt-get update -qq
 | 
			
		||||
    # Update package lists with retry logic for lock conflicts
 | 
			
		||||
    local retry_count=0
 | 
			
		||||
    local max_retries=3
 | 
			
		||||
    local retry_delay=5
 | 
			
		||||
    
 | 
			
		||||
    while [[ $retry_count -lt $max_retries ]]; do
 | 
			
		||||
        if apt-get update -qq 2>/dev/null; then
 | 
			
		||||
            break
 | 
			
		||||
        else
 | 
			
		||||
            retry_count=$((retry_count + 1))
 | 
			
		||||
            if [[ $retry_count -lt $max_retries ]]; then
 | 
			
		||||
                warning "APT lock detected, retrying in ${retry_delay} seconds... (attempt $retry_count/$max_retries)"
 | 
			
		||||
                sleep $retry_delay
 | 
			
		||||
            else
 | 
			
		||||
                warning "APT lock persists after $max_retries attempts, continuing without update..."
 | 
			
		||||
            fi
 | 
			
		||||
        fi
 | 
			
		||||
    done
 | 
			
		||||
    
 | 
			
		||||
    # Determine upgradable packages using apt-get simulation (compatible with Ubuntu 18.04)
 | 
			
		||||
    # Example line format:
 | 
			
		||||
@@ -626,6 +751,11 @@ get_apt_packages() {
 | 
			
		||||
                is_security_update=true
 | 
			
		||||
            fi
 | 
			
		||||
            
 | 
			
		||||
            # Escape JSON special characters in package data
 | 
			
		||||
            package_name=$(echo "$package_name" | sed 's/"/\\"/g' | sed 's/\\/\\\\/g')
 | 
			
		||||
            current_version=$(echo "$current_version" | sed 's/"/\\"/g' | sed 's/\\/\\\\/g')
 | 
			
		||||
            available_version=$(echo "$available_version" | sed 's/"/\\"/g' | sed 's/\\/\\\\/g')
 | 
			
		||||
            
 | 
			
		||||
            if [[ "$first_ref" == true ]]; then
 | 
			
		||||
                first_ref=false
 | 
			
		||||
            else
 | 
			
		||||
@@ -637,12 +767,16 @@ get_apt_packages() {
 | 
			
		||||
    done <<< "$upgradable_sim"
 | 
			
		||||
    
 | 
			
		||||
    # Get installed packages that are up to date
 | 
			
		||||
    local installed=$(dpkg-query -W -f='${Package} ${Version}\n' | head -100)
 | 
			
		||||
    local installed=$(dpkg-query -W -f='${Package} ${Version}\n')
 | 
			
		||||
    
 | 
			
		||||
    while IFS=' ' read -r package_name version; do
 | 
			
		||||
        if [[ -n "$package_name" && -n "$version" ]]; then
 | 
			
		||||
            # Check if this package is not in the upgrade list
 | 
			
		||||
            if ! echo "$upgradable" | grep -q "^$package_name/"; then
 | 
			
		||||
            if ! echo "$upgradable_sim" | grep -q "^Inst $package_name "; then
 | 
			
		||||
                # Escape JSON special characters in package data
 | 
			
		||||
                package_name=$(echo "$package_name" | sed 's/"/\\"/g' | sed 's/\\/\\\\/g')
 | 
			
		||||
                version=$(echo "$version" | sed 's/"/\\"/g' | sed 's/\\/\\\\/g')
 | 
			
		||||
                
 | 
			
		||||
                if [[ "$first_ref" == true ]]; then
 | 
			
		||||
                    first_ref=false
 | 
			
		||||
                else
 | 
			
		||||
@@ -871,6 +1005,9 @@ get_system_info() {
 | 
			
		||||
send_update() {
 | 
			
		||||
    load_credentials
 | 
			
		||||
    
 | 
			
		||||
    # Track execution start time
 | 
			
		||||
    local start_time=$(date +%s.%N)
 | 
			
		||||
    
 | 
			
		||||
    # Verify datetime before proceeding
 | 
			
		||||
    if ! verify_datetime; then
 | 
			
		||||
        warning "Datetime verification failed, but continuing with update..."
 | 
			
		||||
@@ -883,6 +1020,15 @@ send_update() {
 | 
			
		||||
    local network_json=$(get_network_info)
 | 
			
		||||
    local system_json=$(get_system_info)
 | 
			
		||||
    
 | 
			
		||||
    # Validate JSON before sending
 | 
			
		||||
    if ! echo "$packages_json" | jq empty 2>/dev/null; then
 | 
			
		||||
        error "Invalid packages JSON generated: $packages_json"
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    if ! echo "$repositories_json" | jq empty 2>/dev/null; then
 | 
			
		||||
        error "Invalid repositories JSON generated: $repositories_json"
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    info "Sending update to PatchMon server..."
 | 
			
		||||
    
 | 
			
		||||
    # Merge all JSON objects into one
 | 
			
		||||
@@ -890,6 +1036,10 @@ send_update() {
 | 
			
		||||
    # Get machine ID
 | 
			
		||||
    local machine_id=$(get_machine_id)
 | 
			
		||||
    
 | 
			
		||||
    # Calculate execution time (in seconds with decimals)
 | 
			
		||||
    local end_time=$(date +%s.%N)
 | 
			
		||||
    local execution_time=$(echo "$end_time - $start_time" | bc)
 | 
			
		||||
    
 | 
			
		||||
    # Create the base payload and merge with system info
 | 
			
		||||
    local base_payload=$(cat <<EOF
 | 
			
		||||
{
 | 
			
		||||
@@ -901,7 +1051,8 @@ send_update() {
 | 
			
		||||
    "ip": "$IP_ADDRESS",
 | 
			
		||||
    "architecture": "$ARCHITECTURE",
 | 
			
		||||
    "agentVersion": "$AGENT_VERSION",
 | 
			
		||||
    "machineId": "$machine_id"
 | 
			
		||||
    "machineId": "$machine_id",
 | 
			
		||||
    "executionTime": $execution_time
 | 
			
		||||
}
 | 
			
		||||
EOF
 | 
			
		||||
)
 | 
			
		||||
@@ -909,15 +1060,27 @@ EOF
 | 
			
		||||
    # Merge the base payload with the system information
 | 
			
		||||
    local payload=$(echo "$base_payload $merged_json" | jq -s '.[0] * .[1]')
 | 
			
		||||
    
 | 
			
		||||
    # Write payload to temporary file to avoid "Argument list too long" error
 | 
			
		||||
    local temp_payload_file=$(mktemp)
 | 
			
		||||
    echo "$payload" > "$temp_payload_file"
 | 
			
		||||
    
 | 
			
		||||
    # Debug: Show payload size
 | 
			
		||||
    local payload_size=$(wc -c < "$temp_payload_file")
 | 
			
		||||
    echo -e "${BLUE}ℹ️  📊 Payload size: $payload_size bytes${NC}"
 | 
			
		||||
    
 | 
			
		||||
    local response=$(curl $CURL_FLAGS -X POST \
 | 
			
		||||
        -H "Content-Type: application/json" \
 | 
			
		||||
        -H "X-API-ID: $API_ID" \
 | 
			
		||||
        -H "X-API-KEY: $API_KEY" \
 | 
			
		||||
        -d "$payload" \
 | 
			
		||||
        "$PATCHMON_SERVER/api/$API_VERSION/hosts/update")
 | 
			
		||||
        -d @"$temp_payload_file" \
 | 
			
		||||
        "$PATCHMON_SERVER/api/$API_VERSION/hosts/update" 2>&1)
 | 
			
		||||
    
 | 
			
		||||
    if [[ $? -eq 0 ]]; then
 | 
			
		||||
    local curl_exit_code=$?
 | 
			
		||||
    
 | 
			
		||||
    # Clean up temporary file
 | 
			
		||||
    rm -f "$temp_payload_file"
 | 
			
		||||
    
 | 
			
		||||
    if [[ $curl_exit_code -eq 0 ]]; then
 | 
			
		||||
        if echo "$response" | grep -q "success"; then
 | 
			
		||||
            local packages_count=$(echo "$response" | grep -o '"packagesProcessed":[0-9]*' | cut -d':' -f2)
 | 
			
		||||
            success "Update sent successfully (${packages_count} packages processed)"
 | 
			
		||||
@@ -953,7 +1116,7 @@ EOF
 | 
			
		||||
            error "Update failed: $response"
 | 
			
		||||
        fi
 | 
			
		||||
    else
 | 
			
		||||
        error "Failed to send update"
 | 
			
		||||
        error "Failed to send update (curl exit code: $curl_exit_code): $response"
 | 
			
		||||
    fi
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -31,3 +31,8 @@ JWT_SECRET=your-secure-random-secret-key-change-this-in-production
 | 
			
		||||
JWT_EXPIRES_IN=1h
 | 
			
		||||
JWT_REFRESH_EXPIRES_IN=7d
 | 
			
		||||
SESSION_INACTIVITY_TIMEOUT_MINUTES=30
 | 
			
		||||
 | 
			
		||||
# TFA Configuration
 | 
			
		||||
TFA_REMEMBER_ME_EXPIRES_IN=30d
 | 
			
		||||
TFA_MAX_REMEMBER_SESSIONS=5
 | 
			
		||||
TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=3
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
	"name": "patchmon-backend",
 | 
			
		||||
	"version": "1.2.7",
 | 
			
		||||
	"version": "1.2.8",
 | 
			
		||||
	"description": "Backend API for Linux Patch Monitoring System",
 | 
			
		||||
	"license": "AGPL-3.0",
 | 
			
		||||
	"main": "src/server.js",
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,6 @@
 | 
			
		||||
-- Add TFA remember me fields to user_sessions table
 | 
			
		||||
ALTER TABLE "user_sessions" ADD COLUMN "tfa_remember_me" BOOLEAN NOT NULL DEFAULT false;
 | 
			
		||||
ALTER TABLE "user_sessions" ADD COLUMN "tfa_bypass_until" TIMESTAMP(3);
 | 
			
		||||
 | 
			
		||||
-- Create index for TFA bypass until field for efficient querying
 | 
			
		||||
CREATE INDEX "user_sessions_tfa_bypass_until_idx" ON "user_sessions"("tfa_bypass_until");
 | 
			
		||||
@@ -0,0 +1,7 @@
 | 
			
		||||
-- Add security fields to user_sessions table for production-ready remember me
 | 
			
		||||
ALTER TABLE "user_sessions" ADD COLUMN "device_fingerprint" TEXT;
 | 
			
		||||
ALTER TABLE "user_sessions" ADD COLUMN "login_count" INTEGER NOT NULL DEFAULT 1;
 | 
			
		||||
ALTER TABLE "user_sessions" ADD COLUMN "last_login_ip" TEXT;
 | 
			
		||||
 | 
			
		||||
-- Create index for device fingerprint for efficient querying
 | 
			
		||||
CREATE INDEX "user_sessions_device_fingerprint_idx" ON "user_sessions"("device_fingerprint");
 | 
			
		||||
@@ -0,0 +1,3 @@
 | 
			
		||||
-- AlterTable
 | 
			
		||||
ALTER TABLE "update_history" ADD COLUMN "total_packages" INTEGER;
 | 
			
		||||
 | 
			
		||||
@@ -0,0 +1,4 @@
 | 
			
		||||
-- AlterTable
 | 
			
		||||
ALTER TABLE "update_history" ADD COLUMN "payload_size_kb" DOUBLE PRECISION;
 | 
			
		||||
ALTER TABLE "update_history" ADD COLUMN "execution_time" DOUBLE PRECISION;
 | 
			
		||||
 | 
			
		||||
@@ -0,0 +1,30 @@
 | 
			
		||||
-- Add indexes to host_packages table for performance optimization
 | 
			
		||||
-- These indexes will dramatically speed up queries filtering by host_id, package_id, needs_update, and is_security_update
 | 
			
		||||
 | 
			
		||||
-- Index for queries filtering by host_id (very common - used when viewing packages for a specific host)
 | 
			
		||||
CREATE INDEX IF NOT EXISTS "host_packages_host_id_idx" ON "host_packages"("host_id");
 | 
			
		||||
 | 
			
		||||
-- Index for queries filtering by package_id (used when finding hosts for a specific package)
 | 
			
		||||
CREATE INDEX IF NOT EXISTS "host_packages_package_id_idx" ON "host_packages"("package_id");
 | 
			
		||||
 | 
			
		||||
-- Index for queries filtering by needs_update (used when finding outdated packages)
 | 
			
		||||
CREATE INDEX IF NOT EXISTS "host_packages_needs_update_idx" ON "host_packages"("needs_update");
 | 
			
		||||
 | 
			
		||||
-- Index for queries filtering by is_security_update (used when finding security updates)
 | 
			
		||||
CREATE INDEX IF NOT EXISTS "host_packages_is_security_update_idx" ON "host_packages"("is_security_update");
 | 
			
		||||
 | 
			
		||||
-- Composite index for the most common query pattern: host_id + needs_update
 | 
			
		||||
-- This is optimal for "show me outdated packages for this host"
 | 
			
		||||
CREATE INDEX IF NOT EXISTS "host_packages_host_id_needs_update_idx" ON "host_packages"("host_id", "needs_update");
 | 
			
		||||
 | 
			
		||||
-- Composite index for host_id + needs_update + is_security_update
 | 
			
		||||
-- This is optimal for "show me security updates for this host"
 | 
			
		||||
CREATE INDEX IF NOT EXISTS "host_packages_host_id_needs_update_security_idx" ON "host_packages"("host_id", "needs_update", "is_security_update");
 | 
			
		||||
 | 
			
		||||
-- Index for queries filtering by package_id + needs_update
 | 
			
		||||
-- This is optimal for "show me hosts where this package needs updates"
 | 
			
		||||
CREATE INDEX IF NOT EXISTS "host_packages_package_id_needs_update_idx" ON "host_packages"("package_id", "needs_update");
 | 
			
		||||
 | 
			
		||||
-- Index on last_checked for cleanup/maintenance queries
 | 
			
		||||
CREATE INDEX IF NOT EXISTS "host_packages_last_checked_idx" ON "host_packages"("last_checked");
 | 
			
		||||
 | 
			
		||||
@@ -44,6 +44,14 @@ model host_packages {
 | 
			
		||||
  packages           packages @relation(fields: [package_id], references: [id], onDelete: Cascade)
 | 
			
		||||
 | 
			
		||||
  @@unique([host_id, package_id])
 | 
			
		||||
  @@index([host_id])
 | 
			
		||||
  @@index([package_id])
 | 
			
		||||
  @@index([needs_update])
 | 
			
		||||
  @@index([is_security_update])
 | 
			
		||||
  @@index([host_id, needs_update])
 | 
			
		||||
  @@index([host_id, needs_update, is_security_update])
 | 
			
		||||
  @@index([package_id, needs_update])
 | 
			
		||||
  @@index([last_checked])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model host_repositories {
 | 
			
		||||
@@ -108,6 +116,9 @@ model packages {
 | 
			
		||||
  created_at     DateTime        @default(now())
 | 
			
		||||
  updated_at     DateTime
 | 
			
		||||
  host_packages  host_packages[]
 | 
			
		||||
 | 
			
		||||
  @@index([name])
 | 
			
		||||
  @@index([category])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model repositories {
 | 
			
		||||
@@ -170,14 +181,17 @@ model settings {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model update_history {
 | 
			
		||||
  id             String   @id
 | 
			
		||||
  host_id        String
 | 
			
		||||
  packages_count Int
 | 
			
		||||
  security_count Int
 | 
			
		||||
  timestamp      DateTime @default(now())
 | 
			
		||||
  status         String   @default("success")
 | 
			
		||||
  error_message  String?
 | 
			
		||||
  hosts          hosts    @relation(fields: [host_id], references: [id], onDelete: Cascade)
 | 
			
		||||
  id              String   @id
 | 
			
		||||
  host_id         String
 | 
			
		||||
  packages_count  Int
 | 
			
		||||
  security_count  Int
 | 
			
		||||
  total_packages  Int?
 | 
			
		||||
  payload_size_kb Float?
 | 
			
		||||
  execution_time  Float?
 | 
			
		||||
  timestamp       DateTime @default(now())
 | 
			
		||||
  status          String   @default("success")
 | 
			
		||||
  error_message   String?
 | 
			
		||||
  hosts           hosts    @relation(fields: [host_id], references: [id], onDelete: Cascade)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model users {
 | 
			
		||||
@@ -207,15 +221,22 @@ model user_sessions {
 | 
			
		||||
  access_token_hash String?
 | 
			
		||||
  ip_address        String?
 | 
			
		||||
  user_agent        String?
 | 
			
		||||
  device_fingerprint String?
 | 
			
		||||
  last_activity     DateTime @default(now())
 | 
			
		||||
  expires_at        DateTime
 | 
			
		||||
  created_at        DateTime @default(now())
 | 
			
		||||
  is_revoked        Boolean  @default(false)
 | 
			
		||||
  tfa_remember_me   Boolean  @default(false)
 | 
			
		||||
  tfa_bypass_until  DateTime?
 | 
			
		||||
  login_count       Int      @default(1)
 | 
			
		||||
  last_login_ip     String?
 | 
			
		||||
  users             users    @relation(fields: [user_id], references: [id], onDelete: Cascade)
 | 
			
		||||
 | 
			
		||||
  @@index([user_id])
 | 
			
		||||
  @@index([refresh_token])
 | 
			
		||||
  @@index([expires_at])
 | 
			
		||||
  @@index([tfa_bypass_until])
 | 
			
		||||
  @@index([device_fingerprint])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model auto_enrollment_tokens {
 | 
			
		||||
 
 | 
			
		||||
@@ -3,6 +3,7 @@ const { PrismaClient } = require("@prisma/client");
 | 
			
		||||
const {
 | 
			
		||||
	validate_session,
 | 
			
		||||
	update_session_activity,
 | 
			
		||||
	is_tfa_bypassed,
 | 
			
		||||
} = require("../utils/session_manager");
 | 
			
		||||
 | 
			
		||||
const prisma = new PrismaClient();
 | 
			
		||||
@@ -46,6 +47,9 @@ const authenticateToken = async (req, res, next) => {
 | 
			
		||||
		// Update session activity timestamp
 | 
			
		||||
		await update_session_activity(decoded.sessionId);
 | 
			
		||||
 | 
			
		||||
		// Check if TFA is bypassed for this session
 | 
			
		||||
		const tfa_bypassed = await is_tfa_bypassed(decoded.sessionId);
 | 
			
		||||
 | 
			
		||||
		// Update last login (only on successful authentication)
 | 
			
		||||
		await prisma.users.update({
 | 
			
		||||
			where: { id: validation.user.id },
 | 
			
		||||
@@ -57,6 +61,7 @@ const authenticateToken = async (req, res, next) => {
 | 
			
		||||
 | 
			
		||||
		req.user = validation.user;
 | 
			
		||||
		req.session_id = decoded.sessionId;
 | 
			
		||||
		req.tfa_bypassed = tfa_bypassed;
 | 
			
		||||
		next();
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		if (error.name === "JsonWebTokenError") {
 | 
			
		||||
@@ -114,8 +119,33 @@ const optionalAuth = async (req, _res, next) => {
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Middleware to check if TFA is required for sensitive operations
 | 
			
		||||
const requireTfaIfEnabled = async (req, res, next) => {
 | 
			
		||||
	try {
 | 
			
		||||
		// Check if user has TFA enabled
 | 
			
		||||
		const user = await prisma.users.findUnique({
 | 
			
		||||
			where: { id: req.user.id },
 | 
			
		||||
			select: { tfa_enabled: true },
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		// If TFA is enabled and not bypassed, require TFA verification
 | 
			
		||||
		if (user?.tfa_enabled && !req.tfa_bypassed) {
 | 
			
		||||
			return res.status(403).json({
 | 
			
		||||
				error: "Two-factor authentication required for this operation",
 | 
			
		||||
				requires_tfa: true,
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		next();
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error("TFA requirement check error:", error);
 | 
			
		||||
		return res.status(500).json({ error: "Authentication check failed" });
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireAdmin,
 | 
			
		||||
	optionalAuth,
 | 
			
		||||
	requireTfaIfEnabled,
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -17,12 +17,65 @@ const {
 | 
			
		||||
	refresh_access_token,
 | 
			
		||||
	revoke_session,
 | 
			
		||||
	revoke_all_user_sessions,
 | 
			
		||||
	get_user_sessions,
 | 
			
		||||
} = require("../utils/session_manager");
 | 
			
		||||
 | 
			
		||||
const router = express.Router();
 | 
			
		||||
const prisma = new PrismaClient();
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Parse user agent string to extract browser and OS info
 | 
			
		||||
 */
 | 
			
		||||
function parse_user_agent(user_agent) {
 | 
			
		||||
	if (!user_agent)
 | 
			
		||||
		return { browser: "Unknown", os: "Unknown", device: "Unknown" };
 | 
			
		||||
 | 
			
		||||
	const ua = user_agent.toLowerCase();
 | 
			
		||||
 | 
			
		||||
	// Browser detection
 | 
			
		||||
	let browser = "Unknown";
 | 
			
		||||
	if (ua.includes("chrome") && !ua.includes("edg")) browser = "Chrome";
 | 
			
		||||
	else if (ua.includes("firefox")) browser = "Firefox";
 | 
			
		||||
	else if (ua.includes("safari") && !ua.includes("chrome")) browser = "Safari";
 | 
			
		||||
	else if (ua.includes("edg")) browser = "Edge";
 | 
			
		||||
	else if (ua.includes("opera")) browser = "Opera";
 | 
			
		||||
 | 
			
		||||
	// OS detection
 | 
			
		||||
	let os = "Unknown";
 | 
			
		||||
	if (ua.includes("windows")) os = "Windows";
 | 
			
		||||
	else if (ua.includes("macintosh") || ua.includes("mac os")) os = "macOS";
 | 
			
		||||
	else if (ua.includes("linux")) os = "Linux";
 | 
			
		||||
	else if (ua.includes("android")) os = "Android";
 | 
			
		||||
	else if (ua.includes("iphone") || ua.includes("ipad")) os = "iOS";
 | 
			
		||||
 | 
			
		||||
	// Device type
 | 
			
		||||
	let device = "Desktop";
 | 
			
		||||
	if (ua.includes("mobile")) device = "Mobile";
 | 
			
		||||
	else if (ua.includes("tablet") || ua.includes("ipad")) device = "Tablet";
 | 
			
		||||
 | 
			
		||||
	return { browser, os, device };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get basic location info from IP (simplified - in production you'd use a service)
 | 
			
		||||
 */
 | 
			
		||||
function get_location_from_ip(ip) {
 | 
			
		||||
	if (!ip) return { country: "Unknown", city: "Unknown" };
 | 
			
		||||
 | 
			
		||||
	// For localhost/private IPs
 | 
			
		||||
	if (
 | 
			
		||||
		ip === "127.0.0.1" ||
 | 
			
		||||
		ip === "::1" ||
 | 
			
		||||
		ip.startsWith("192.168.") ||
 | 
			
		||||
		ip.startsWith("10.")
 | 
			
		||||
	) {
 | 
			
		||||
		return { country: "Local", city: "Local Network" };
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// In a real implementation, you'd use a service like MaxMind GeoIP2
 | 
			
		||||
	// For now, return unknown for external IPs
 | 
			
		||||
	return { country: "Unknown", city: "Unknown" };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Check if any admin users exist (for first-time setup)
 | 
			
		||||
router.get("/check-admin-users", async (_req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
@@ -765,6 +818,8 @@ router.post(
 | 
			
		||||
					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,
 | 
			
		||||
@@ -788,6 +843,10 @@ router.post(
 | 
			
		||||
			.isLength({ min: 6, max: 6 })
 | 
			
		||||
			.withMessage("Token must be 6 digits"),
 | 
			
		||||
		body("token").isNumeric().withMessage("Token must contain only numbers"),
 | 
			
		||||
		body("remember_me")
 | 
			
		||||
			.optional()
 | 
			
		||||
			.isBoolean()
 | 
			
		||||
			.withMessage("Remember me must be a boolean"),
 | 
			
		||||
	],
 | 
			
		||||
	async (req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
@@ -796,7 +855,7 @@ router.post(
 | 
			
		||||
				return res.status(400).json({ errors: errors.array() });
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			const { username, token } = req.body;
 | 
			
		||||
			const { username, token, remember_me = false } = req.body;
 | 
			
		||||
 | 
			
		||||
			// Find user
 | 
			
		||||
			const user = await prisma.users.findFirst({
 | 
			
		||||
@@ -865,13 +924,20 @@ router.post(
 | 
			
		||||
			// Create session with access and refresh tokens
 | 
			
		||||
			const ip_address = req.ip || req.connection.remoteAddress;
 | 
			
		||||
			const user_agent = req.get("user-agent");
 | 
			
		||||
			const session = await create_session(user.id, ip_address, user_agent);
 | 
			
		||||
			const session = await create_session(
 | 
			
		||||
				user.id,
 | 
			
		||||
				ip_address,
 | 
			
		||||
				user_agent,
 | 
			
		||||
				remember_me,
 | 
			
		||||
				req,
 | 
			
		||||
			);
 | 
			
		||||
 | 
			
		||||
			res.json({
 | 
			
		||||
				message: "Login successful",
 | 
			
		||||
				token: session.access_token,
 | 
			
		||||
				refresh_token: session.refresh_token,
 | 
			
		||||
				expires_at: session.expires_at,
 | 
			
		||||
				tfa_bypass_until: session.tfa_bypass_until,
 | 
			
		||||
				user: {
 | 
			
		||||
					id: user.id,
 | 
			
		||||
					username: user.username,
 | 
			
		||||
@@ -1109,10 +1175,43 @@ router.post(
 | 
			
		||||
// Get user's active sessions
 | 
			
		||||
router.get("/sessions", authenticateToken, async (req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const sessions = await get_user_sessions(req.user.id);
 | 
			
		||||
		const sessions = await prisma.user_sessions.findMany({
 | 
			
		||||
			where: {
 | 
			
		||||
				user_id: req.user.id,
 | 
			
		||||
				is_revoked: false,
 | 
			
		||||
				expires_at: { gt: new Date() },
 | 
			
		||||
			},
 | 
			
		||||
			select: {
 | 
			
		||||
				id: true,
 | 
			
		||||
				ip_address: true,
 | 
			
		||||
				user_agent: true,
 | 
			
		||||
				device_fingerprint: true,
 | 
			
		||||
				last_activity: true,
 | 
			
		||||
				created_at: true,
 | 
			
		||||
				expires_at: true,
 | 
			
		||||
				tfa_remember_me: true,
 | 
			
		||||
				tfa_bypass_until: true,
 | 
			
		||||
				login_count: true,
 | 
			
		||||
				last_login_ip: true,
 | 
			
		||||
			},
 | 
			
		||||
			orderBy: { last_activity: "desc" },
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		// Enhance sessions with device info
 | 
			
		||||
		const enhanced_sessions = sessions.map((session) => {
 | 
			
		||||
			const is_current_session = session.id === req.session_id;
 | 
			
		||||
			const device_info = parse_user_agent(session.user_agent);
 | 
			
		||||
 | 
			
		||||
			return {
 | 
			
		||||
				...session,
 | 
			
		||||
				is_current_session,
 | 
			
		||||
				device_info,
 | 
			
		||||
				location_info: get_location_from_ip(session.ip_address),
 | 
			
		||||
			};
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		res.json({
 | 
			
		||||
			sessions: sessions,
 | 
			
		||||
			sessions: enhanced_sessions,
 | 
			
		||||
		});
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error("Get sessions error:", error);
 | 
			
		||||
@@ -1134,6 +1233,11 @@ router.delete("/sessions/:session_id", authenticateToken, async (req, res) => {
 | 
			
		||||
			return res.status(404).json({ error: "Session not found" });
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Don't allow revoking the current session
 | 
			
		||||
		if (session_id === req.session_id) {
 | 
			
		||||
			return res.status(400).json({ error: "Cannot revoke current session" });
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		await revoke_session(session_id);
 | 
			
		||||
 | 
			
		||||
		res.json({
 | 
			
		||||
@@ -1145,4 +1249,25 @@ router.delete("/sessions/:session_id", authenticateToken, async (req, res) => {
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Revoke all sessions except current one
 | 
			
		||||
router.delete("/sessions", authenticateToken, async (req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		// Revoke all sessions except the current one
 | 
			
		||||
		await prisma.user_sessions.updateMany({
 | 
			
		||||
			where: {
 | 
			
		||||
				user_id: req.user.id,
 | 
			
		||||
				id: { not: req.session_id },
 | 
			
		||||
			},
 | 
			
		||||
			data: { is_revoked: true },
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		res.json({
 | 
			
		||||
			message: "All other sessions revoked successfully",
 | 
			
		||||
		});
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error("Revoke all sessions error:", error);
 | 
			
		||||
		res.status(500).json({ error: "Failed to revoke sessions" });
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
module.exports = router;
 | 
			
		||||
 
 | 
			
		||||
@@ -130,15 +130,20 @@ async function createDefaultDashboardPreferences(userId, userRole = "user") {
 | 
			
		||||
				requiredPermission: "can_view_packages",
 | 
			
		||||
				order: 13,
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				cardId: "packageTrends",
 | 
			
		||||
				requiredPermission: "can_view_packages",
 | 
			
		||||
				order: 14,
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				cardId: "recentUsers",
 | 
			
		||||
				requiredPermission: "can_view_users",
 | 
			
		||||
				order: 14,
 | 
			
		||||
				order: 15,
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				cardId: "quickStats",
 | 
			
		||||
				requiredPermission: "can_view_dashboard",
 | 
			
		||||
				order: 15,
 | 
			
		||||
				order: 16,
 | 
			
		||||
			},
 | 
			
		||||
		];
 | 
			
		||||
 | 
			
		||||
@@ -341,19 +346,26 @@ router.get("/defaults", authenticateToken, async (_req, res) => {
 | 
			
		||||
				enabled: true,
 | 
			
		||||
				order: 13,
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				cardId: "packageTrends",
 | 
			
		||||
				title: "Package Trends",
 | 
			
		||||
				icon: "TrendingUp",
 | 
			
		||||
				enabled: true,
 | 
			
		||||
				order: 14,
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				cardId: "recentUsers",
 | 
			
		||||
				title: "Recent Users Logged in",
 | 
			
		||||
				icon: "Users",
 | 
			
		||||
				enabled: true,
 | 
			
		||||
				order: 14,
 | 
			
		||||
				order: 15,
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				cardId: "quickStats",
 | 
			
		||||
				title: "Quick Stats",
 | 
			
		||||
				icon: "TrendingUp",
 | 
			
		||||
				enabled: true,
 | 
			
		||||
				order: 15,
 | 
			
		||||
				order: 16,
 | 
			
		||||
			},
 | 
			
		||||
		];
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -145,9 +145,13 @@ router.get(
 | 
			
		||||
			];
 | 
			
		||||
 | 
			
		||||
			// Package update priority distribution
 | 
			
		||||
			const regularUpdates = Math.max(
 | 
			
		||||
				0,
 | 
			
		||||
				totalOutdatedPackages - securityUpdates,
 | 
			
		||||
			);
 | 
			
		||||
			const packageUpdateDistribution = [
 | 
			
		||||
				{ name: "Security", count: securityUpdates },
 | 
			
		||||
				{ name: "Regular", count: totalOutdatedPackages - securityUpdates },
 | 
			
		||||
				{ name: "Regular", count: regularUpdates },
 | 
			
		||||
			];
 | 
			
		||||
 | 
			
		||||
			res.json({
 | 
			
		||||
@@ -343,32 +347,41 @@ router.get(
 | 
			
		||||
		try {
 | 
			
		||||
			const { hostId } = req.params;
 | 
			
		||||
 | 
			
		||||
			const host = await prisma.hosts.findUnique({
 | 
			
		||||
				where: { id: hostId },
 | 
			
		||||
				include: {
 | 
			
		||||
					host_groups: {
 | 
			
		||||
						select: {
 | 
			
		||||
							id: true,
 | 
			
		||||
							name: true,
 | 
			
		||||
							color: true,
 | 
			
		||||
			const limit = parseInt(req.query.limit, 10) || 10;
 | 
			
		||||
			const offset = parseInt(req.query.offset, 10) || 0;
 | 
			
		||||
 | 
			
		||||
			const [host, totalHistoryCount] = await Promise.all([
 | 
			
		||||
				prisma.hosts.findUnique({
 | 
			
		||||
					where: { id: hostId },
 | 
			
		||||
					include: {
 | 
			
		||||
						host_groups: {
 | 
			
		||||
							select: {
 | 
			
		||||
								id: true,
 | 
			
		||||
								name: true,
 | 
			
		||||
								color: true,
 | 
			
		||||
							},
 | 
			
		||||
						},
 | 
			
		||||
						host_packages: {
 | 
			
		||||
							include: {
 | 
			
		||||
								packages: true,
 | 
			
		||||
							},
 | 
			
		||||
							orderBy: {
 | 
			
		||||
								needs_update: "desc",
 | 
			
		||||
							},
 | 
			
		||||
						},
 | 
			
		||||
						update_history: {
 | 
			
		||||
							orderBy: {
 | 
			
		||||
								timestamp: "desc",
 | 
			
		||||
							},
 | 
			
		||||
							take: limit,
 | 
			
		||||
							skip: offset,
 | 
			
		||||
						},
 | 
			
		||||
					},
 | 
			
		||||
					host_packages: {
 | 
			
		||||
						include: {
 | 
			
		||||
							packages: true,
 | 
			
		||||
						},
 | 
			
		||||
						orderBy: {
 | 
			
		||||
							needs_update: "desc",
 | 
			
		||||
						},
 | 
			
		||||
					},
 | 
			
		||||
					update_history: {
 | 
			
		||||
						orderBy: {
 | 
			
		||||
							timestamp: "desc",
 | 
			
		||||
						},
 | 
			
		||||
						take: 10,
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
				}),
 | 
			
		||||
				prisma.update_history.count({
 | 
			
		||||
					where: { host_id: hostId },
 | 
			
		||||
				}),
 | 
			
		||||
			]);
 | 
			
		||||
 | 
			
		||||
			if (!host) {
 | 
			
		||||
				return res.status(404).json({ error: "Host not found" });
 | 
			
		||||
@@ -384,6 +397,12 @@ router.get(
 | 
			
		||||
						(hp) => hp.needs_update && hp.is_security_update,
 | 
			
		||||
					).length,
 | 
			
		||||
				},
 | 
			
		||||
				pagination: {
 | 
			
		||||
					total: totalHistoryCount,
 | 
			
		||||
					limit,
 | 
			
		||||
					offset,
 | 
			
		||||
					hasMore: offset + limit < totalHistoryCount,
 | 
			
		||||
				},
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			res.json(hostWithStats);
 | 
			
		||||
@@ -456,4 +475,132 @@ router.get(
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Get package trends over time
 | 
			
		||||
router.get(
 | 
			
		||||
	"/package-trends",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireViewHosts,
 | 
			
		||||
	async (req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
			const { days = 30, hostId } = req.query;
 | 
			
		||||
			const daysInt = parseInt(days, 10);
 | 
			
		||||
 | 
			
		||||
			// Calculate date range
 | 
			
		||||
			const endDate = new Date();
 | 
			
		||||
			const startDate = new Date();
 | 
			
		||||
			startDate.setDate(endDate.getDate() - daysInt);
 | 
			
		||||
 | 
			
		||||
			// Build where clause
 | 
			
		||||
			const whereClause = {
 | 
			
		||||
				timestamp: {
 | 
			
		||||
					gte: startDate,
 | 
			
		||||
					lte: endDate,
 | 
			
		||||
				},
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			// Add host filter if specified
 | 
			
		||||
			if (hostId && hostId !== "all" && hostId !== "undefined") {
 | 
			
		||||
				whereClause.host_id = hostId;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Get all update history records in the date range
 | 
			
		||||
			const trendsData = await prisma.update_history.findMany({
 | 
			
		||||
				where: whereClause,
 | 
			
		||||
				select: {
 | 
			
		||||
					timestamp: true,
 | 
			
		||||
					packages_count: true,
 | 
			
		||||
					security_count: true,
 | 
			
		||||
					total_packages: true,
 | 
			
		||||
				},
 | 
			
		||||
				orderBy: {
 | 
			
		||||
					timestamp: "asc",
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			// Process data to show actual values (no averaging)
 | 
			
		||||
			const processedData = trendsData
 | 
			
		||||
				.filter((record) => record.total_packages !== null) // Only include records with valid data
 | 
			
		||||
				.map((record) => {
 | 
			
		||||
					const date = new Date(record.timestamp);
 | 
			
		||||
					let timeKey;
 | 
			
		||||
 | 
			
		||||
					if (daysInt <= 1) {
 | 
			
		||||
						// For hourly view, use exact timestamp
 | 
			
		||||
						timeKey = date.toISOString().substring(0, 16); // YYYY-MM-DDTHH:MM
 | 
			
		||||
					} else {
 | 
			
		||||
						// For daily view, group by day
 | 
			
		||||
						timeKey = date.toISOString().split("T")[0]; // YYYY-MM-DD
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					return {
 | 
			
		||||
						timeKey,
 | 
			
		||||
						total_packages: record.total_packages,
 | 
			
		||||
						packages_count: record.packages_count || 0,
 | 
			
		||||
						security_count: record.security_count || 0,
 | 
			
		||||
					};
 | 
			
		||||
				})
 | 
			
		||||
				.sort((a, b) => a.timeKey.localeCompare(b.timeKey)); // Sort by time
 | 
			
		||||
 | 
			
		||||
			// Get hosts list for dropdown (always fetch for dropdown functionality)
 | 
			
		||||
			const hostsList = await prisma.hosts.findMany({
 | 
			
		||||
				select: {
 | 
			
		||||
					id: true,
 | 
			
		||||
					friendly_name: true,
 | 
			
		||||
					hostname: true,
 | 
			
		||||
				},
 | 
			
		||||
				orderBy: {
 | 
			
		||||
					friendly_name: "asc",
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			// Format data for chart
 | 
			
		||||
			const chartData = {
 | 
			
		||||
				labels: [],
 | 
			
		||||
				datasets: [
 | 
			
		||||
					{
 | 
			
		||||
						label: "Total Packages",
 | 
			
		||||
						data: [],
 | 
			
		||||
						borderColor: "#3B82F6", // Blue
 | 
			
		||||
						backgroundColor: "rgba(59, 130, 246, 0.1)",
 | 
			
		||||
						tension: 0.4,
 | 
			
		||||
						hidden: true, // Hidden by default
 | 
			
		||||
					},
 | 
			
		||||
					{
 | 
			
		||||
						label: "Outdated Packages",
 | 
			
		||||
						data: [],
 | 
			
		||||
						borderColor: "#F59E0B", // Orange
 | 
			
		||||
						backgroundColor: "rgba(245, 158, 11, 0.1)",
 | 
			
		||||
						tension: 0.4,
 | 
			
		||||
					},
 | 
			
		||||
					{
 | 
			
		||||
						label: "Security Packages",
 | 
			
		||||
						data: [],
 | 
			
		||||
						borderColor: "#EF4444", // Red
 | 
			
		||||
						backgroundColor: "rgba(239, 68, 68, 0.1)",
 | 
			
		||||
						tension: 0.4,
 | 
			
		||||
					},
 | 
			
		||||
				],
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			// Process aggregated data
 | 
			
		||||
			processedData.forEach((item) => {
 | 
			
		||||
				chartData.labels.push(item.timeKey);
 | 
			
		||||
				chartData.datasets[0].data.push(item.total_packages);
 | 
			
		||||
				chartData.datasets[1].data.push(item.packages_count);
 | 
			
		||||
				chartData.datasets[2].data.push(item.security_count);
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			res.json({
 | 
			
		||||
				chartData,
 | 
			
		||||
				hosts: hostsList,
 | 
			
		||||
				period: daysInt,
 | 
			
		||||
				hostId: hostId || "all",
 | 
			
		||||
			});
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error("Error fetching package trends:", error);
 | 
			
		||||
			res.status(500).json({ error: "Failed to fetch package trends" });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
module.exports = router;
 | 
			
		||||
 
 | 
			
		||||
@@ -325,9 +325,13 @@ router.post(
 | 
			
		||||
				return res.status(400).json({ errors: errors.array() });
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			const { packages, repositories } = req.body;
 | 
			
		||||
			const { packages, repositories, executionTime } = req.body;
 | 
			
		||||
			const host = req.hostRecord;
 | 
			
		||||
 | 
			
		||||
			// Calculate payload size in KB
 | 
			
		||||
			const payloadSizeBytes = JSON.stringify(req.body).length;
 | 
			
		||||
			const payloadSizeKb = payloadSizeBytes / 1024;
 | 
			
		||||
 | 
			
		||||
			// Update host last update timestamp and system info if provided
 | 
			
		||||
			const updateData = {
 | 
			
		||||
				last_update: new Date(),
 | 
			
		||||
@@ -383,6 +387,7 @@ router.post(
 | 
			
		||||
				(pkg) => pkg.isSecurityUpdate,
 | 
			
		||||
			).length;
 | 
			
		||||
			const updatesCount = packages.filter((pkg) => pkg.needsUpdate).length;
 | 
			
		||||
			const totalPackages = packages.length;
 | 
			
		||||
 | 
			
		||||
			// Process everything in a single transaction to avoid race conditions
 | 
			
		||||
			await prisma.$transaction(async (tx) => {
 | 
			
		||||
@@ -525,6 +530,9 @@ router.post(
 | 
			
		||||
						host_id: host.id,
 | 
			
		||||
						packages_count: updatesCount,
 | 
			
		||||
						security_count: securityCount,
 | 
			
		||||
						total_packages: totalPackages,
 | 
			
		||||
						payload_size_kb: payloadSizeKb,
 | 
			
		||||
						execution_time: executionTime ? parseFloat(executionTime) : null,
 | 
			
		||||
						status: "success",
 | 
			
		||||
					},
 | 
			
		||||
				});
 | 
			
		||||
 
 | 
			
		||||
@@ -14,6 +14,7 @@ router.get("/", async (req, res) => {
 | 
			
		||||
			category = "",
 | 
			
		||||
			needsUpdate = "",
 | 
			
		||||
			isSecurityUpdate = "",
 | 
			
		||||
			host = "",
 | 
			
		||||
		} = req.query;
 | 
			
		||||
 | 
			
		||||
		const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
 | 
			
		||||
@@ -33,8 +34,27 @@ router.get("/", async (req, res) => {
 | 
			
		||||
					: {},
 | 
			
		||||
				// Category filter
 | 
			
		||||
				category ? { category: { equals: category } } : {},
 | 
			
		||||
				// Update status filters
 | 
			
		||||
				needsUpdate
 | 
			
		||||
				// Host filter - only return packages installed on the specified host
 | 
			
		||||
				// Combined with update status filters if both are present
 | 
			
		||||
				host
 | 
			
		||||
					? {
 | 
			
		||||
							host_packages: {
 | 
			
		||||
								some: {
 | 
			
		||||
									host_id: host,
 | 
			
		||||
									// If needsUpdate or isSecurityUpdate filters are present, apply them here
 | 
			
		||||
									...(needsUpdate
 | 
			
		||||
										? { needs_update: needsUpdate === "true" }
 | 
			
		||||
										: {}),
 | 
			
		||||
									...(isSecurityUpdate
 | 
			
		||||
										? { is_security_update: isSecurityUpdate === "true" }
 | 
			
		||||
										: {}),
 | 
			
		||||
								},
 | 
			
		||||
							},
 | 
			
		||||
						}
 | 
			
		||||
					: {},
 | 
			
		||||
				// Update status filters (only applied if no host filter)
 | 
			
		||||
				// If host filter is present, these are already applied above
 | 
			
		||||
				!host && needsUpdate
 | 
			
		||||
					? {
 | 
			
		||||
							host_packages: {
 | 
			
		||||
								some: {
 | 
			
		||||
@@ -43,7 +63,7 @@ router.get("/", async (req, res) => {
 | 
			
		||||
							},
 | 
			
		||||
						}
 | 
			
		||||
					: {},
 | 
			
		||||
				isSecurityUpdate
 | 
			
		||||
				!host && isSecurityUpdate
 | 
			
		||||
					? {
 | 
			
		||||
							host_packages: {
 | 
			
		||||
								some: {
 | 
			
		||||
@@ -84,24 +104,32 @@ router.get("/", async (req, res) => {
 | 
			
		||||
		// Get additional stats for each package
 | 
			
		||||
		const packagesWithStats = await Promise.all(
 | 
			
		||||
			packages.map(async (pkg) => {
 | 
			
		||||
				// Build base where clause for this package
 | 
			
		||||
				const baseWhere = { package_id: pkg.id };
 | 
			
		||||
 | 
			
		||||
				// If host filter is specified, add host filter to all queries
 | 
			
		||||
				const hostWhere = host ? { ...baseWhere, host_id: host } : baseWhere;
 | 
			
		||||
 | 
			
		||||
				const [updatesCount, securityCount, packageHosts] = await Promise.all([
 | 
			
		||||
					prisma.host_packages.count({
 | 
			
		||||
						where: {
 | 
			
		||||
							package_id: pkg.id,
 | 
			
		||||
							...hostWhere,
 | 
			
		||||
							needs_update: true,
 | 
			
		||||
						},
 | 
			
		||||
					}),
 | 
			
		||||
					prisma.host_packages.count({
 | 
			
		||||
						where: {
 | 
			
		||||
							package_id: pkg.id,
 | 
			
		||||
							...hostWhere,
 | 
			
		||||
							needs_update: true,
 | 
			
		||||
							is_security_update: true,
 | 
			
		||||
						},
 | 
			
		||||
					}),
 | 
			
		||||
					prisma.host_packages.findMany({
 | 
			
		||||
						where: {
 | 
			
		||||
							package_id: pkg.id,
 | 
			
		||||
							needs_update: true,
 | 
			
		||||
							...hostWhere,
 | 
			
		||||
							// If host filter is specified, include all packages for that host
 | 
			
		||||
							// Otherwise, only include packages that need updates
 | 
			
		||||
							...(host ? {} : { needs_update: true }),
 | 
			
		||||
						},
 | 
			
		||||
						select: {
 | 
			
		||||
							hosts: {
 | 
			
		||||
@@ -112,6 +140,10 @@ router.get("/", async (req, res) => {
 | 
			
		||||
									os_type: true,
 | 
			
		||||
								},
 | 
			
		||||
							},
 | 
			
		||||
							current_version: true,
 | 
			
		||||
							available_version: true,
 | 
			
		||||
							needs_update: true,
 | 
			
		||||
							is_security_update: true,
 | 
			
		||||
						},
 | 
			
		||||
						take: 10, // Limit to first 10 for performance
 | 
			
		||||
					}),
 | 
			
		||||
 
 | 
			
		||||
@@ -14,13 +14,13 @@ const router = express.Router();
 | 
			
		||||
function getCurrentVersion() {
 | 
			
		||||
	try {
 | 
			
		||||
		const packageJson = require("../../package.json");
 | 
			
		||||
		return packageJson?.version || "1.2.7";
 | 
			
		||||
		return packageJson?.version || "1.2.8";
 | 
			
		||||
	} catch (packageError) {
 | 
			
		||||
		console.warn(
 | 
			
		||||
			"Could not read version from package.json, using fallback:",
 | 
			
		||||
			packageError.message,
 | 
			
		||||
		);
 | 
			
		||||
		return "1.2.7";
 | 
			
		||||
		return "1.2.8";
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -126,43 +126,61 @@ async function getLatestCommit(owner, repo) {
 | 
			
		||||
 | 
			
		||||
// 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`;
 | 
			
		||||
	// Try both with and without 'v' prefix for compatibility
 | 
			
		||||
	const versionTags = [
 | 
			
		||||
		currentVersion, // Try without 'v' first (new format)
 | 
			
		||||
		`v${currentVersion}`, // Try with 'v' prefix (old format)
 | 
			
		||||
	];
 | 
			
		||||
 | 
			
		||||
		const response = await fetch(apiUrl, {
 | 
			
		||||
			method: "GET",
 | 
			
		||||
			headers: {
 | 
			
		||||
				Accept: "application/vnd.github.v3+json",
 | 
			
		||||
				"User-Agent": `PatchMon-Server/${getCurrentVersion()}`,
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
	for (const versionTag of versionTags) {
 | 
			
		||||
		try {
 | 
			
		||||
			// Compare main branch with the released version tag
 | 
			
		||||
			const apiUrl = `https://api.github.com/repos/${owner}/${repo}/compare/${versionTag}...main`;
 | 
			
		||||
 | 
			
		||||
		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");
 | 
			
		||||
			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");
 | 
			
		||||
				}
 | 
			
		||||
				// If 404, try next tag format
 | 
			
		||||
				if (response.status === 404) {
 | 
			
		||||
					continue;
 | 
			
		||||
				}
 | 
			
		||||
				throw new Error(
 | 
			
		||||
					`GitHub API error: ${response.status} ${response.statusText}`,
 | 
			
		||||
				);
 | 
			
		||||
			}
 | 
			
		||||
			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;
 | 
			
		||||
			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) {
 | 
			
		||||
			// If rate limit, throw immediately
 | 
			
		||||
			if (error.message.includes("rate limit")) {
 | 
			
		||||
				throw error;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// If all attempts failed, throw error
 | 
			
		||||
	throw new Error(
 | 
			
		||||
		`Could not find tag '${currentVersion}' or 'v${currentVersion}' in repository`,
 | 
			
		||||
	);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Helper function to compare version strings (semantic versioning)
 | 
			
		||||
@@ -274,11 +292,11 @@ router.get(
 | 
			
		||||
					) {
 | 
			
		||||
						console.log("GitHub API rate limited, providing fallback data");
 | 
			
		||||
						latestRelease = {
 | 
			
		||||
							tagName: "v1.2.7",
 | 
			
		||||
							version: "1.2.7",
 | 
			
		||||
							tagName: "1.2.8",
 | 
			
		||||
							version: "1.2.8",
 | 
			
		||||
							publishedAt: "2025-10-02T17:12:53Z",
 | 
			
		||||
							htmlUrl:
 | 
			
		||||
								"https://github.com/PatchMon/PatchMon/releases/tag/v1.2.7",
 | 
			
		||||
								"https://github.com/PatchMon/PatchMon/releases/tag/1.2.8",
 | 
			
		||||
						};
 | 
			
		||||
						latestCommit = {
 | 
			
		||||
							sha: "cc89df161b8ea5d48ff95b0eb405fe69042052cd",
 | 
			
		||||
@@ -296,10 +314,14 @@ router.get(
 | 
			
		||||
						};
 | 
			
		||||
					} else {
 | 
			
		||||
						// Fall back to cached data for other errors
 | 
			
		||||
						const githubRepoUrl = settings.githubRepoUrl || DEFAULT_GITHUB_REPO;
 | 
			
		||||
						latestRelease = settings.latest_version
 | 
			
		||||
							? {
 | 
			
		||||
									version: settings.latest_version,
 | 
			
		||||
									tagName: `v${settings.latest_version}`,
 | 
			
		||||
									tagName: settings.latest_version,
 | 
			
		||||
									publishedAt: null, // Only use date from GitHub API, not cached data
 | 
			
		||||
									// Note: URL may need 'v' prefix depending on actual tag format in repo
 | 
			
		||||
									htmlUrl: `${githubRepoUrl.replace(/\.git$/, "")}/releases/tag/${settings.latest_version}`,
 | 
			
		||||
								}
 | 
			
		||||
							: null;
 | 
			
		||||
					}
 | 
			
		||||
 
 | 
			
		||||
@@ -674,11 +674,16 @@ async function getPermissionBasedPreferences(userRole) {
 | 
			
		||||
			requiredPermission: "can_view_packages",
 | 
			
		||||
			order: 13,
 | 
			
		||||
		},
 | 
			
		||||
		{ cardId: "recentUsers", requiredPermission: "can_view_users", order: 14 },
 | 
			
		||||
		{
 | 
			
		||||
			cardId: "packageTrends",
 | 
			
		||||
			requiredPermission: "can_view_packages",
 | 
			
		||||
			order: 14,
 | 
			
		||||
		},
 | 
			
		||||
		{ cardId: "recentUsers", requiredPermission: "can_view_users", order: 15 },
 | 
			
		||||
		{
 | 
			
		||||
			cardId: "quickStats",
 | 
			
		||||
			requiredPermission: "can_view_dashboard",
 | 
			
		||||
			order: 15,
 | 
			
		||||
			order: 16,
 | 
			
		||||
		},
 | 
			
		||||
	];
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -104,7 +104,7 @@ class UpdateScheduler {
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Read version from package.json dynamically
 | 
			
		||||
			let currentVersion = "1.2.7"; // fallback
 | 
			
		||||
			let currentVersion = "1.2.8"; // fallback
 | 
			
		||||
			try {
 | 
			
		||||
				const packageJson = require("../../package.json");
 | 
			
		||||
				if (packageJson?.version) {
 | 
			
		||||
@@ -214,7 +214,7 @@ class UpdateScheduler {
 | 
			
		||||
			const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
 | 
			
		||||
 | 
			
		||||
			// Get current version for User-Agent
 | 
			
		||||
			let currentVersion = "1.2.7"; // fallback
 | 
			
		||||
			let currentVersion = "1.2.8"; // fallback
 | 
			
		||||
			try {
 | 
			
		||||
				const packageJson = require("../../package.json");
 | 
			
		||||
				if (packageJson?.version) {
 | 
			
		||||
 
 | 
			
		||||
@@ -15,6 +15,16 @@ if (!process.env.JWT_SECRET) {
 | 
			
		||||
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 TFA_REMEMBER_ME_EXPIRES_IN =
 | 
			
		||||
	process.env.TFA_REMEMBER_ME_EXPIRES_IN || "30d";
 | 
			
		||||
const TFA_MAX_REMEMBER_SESSIONS = parseInt(
 | 
			
		||||
	process.env.TFA_MAX_REMEMBER_SESSIONS || "5",
 | 
			
		||||
	10,
 | 
			
		||||
);
 | 
			
		||||
const TFA_SUSPICIOUS_ACTIVITY_THRESHOLD = parseInt(
 | 
			
		||||
	process.env.TFA_SUSPICIOUS_ACTIVITY_THRESHOLD || "3",
 | 
			
		||||
	10,
 | 
			
		||||
);
 | 
			
		||||
const INACTIVITY_TIMEOUT_MINUTES = parseInt(
 | 
			
		||||
	process.env.SESSION_INACTIVITY_TIMEOUT_MINUTES || "30",
 | 
			
		||||
	10,
 | 
			
		||||
@@ -70,16 +80,136 @@ function parse_expiration(expiration_string) {
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Generate device fingerprint from request data
 | 
			
		||||
 */
 | 
			
		||||
function generate_device_fingerprint(req) {
 | 
			
		||||
	const components = [
 | 
			
		||||
		req.get("user-agent") || "",
 | 
			
		||||
		req.get("accept-language") || "",
 | 
			
		||||
		req.get("accept-encoding") || "",
 | 
			
		||||
		req.ip || "",
 | 
			
		||||
	];
 | 
			
		||||
 | 
			
		||||
	// Create a simple hash of device characteristics
 | 
			
		||||
	const fingerprint = crypto
 | 
			
		||||
		.createHash("sha256")
 | 
			
		||||
		.update(components.join("|"))
 | 
			
		||||
		.digest("hex")
 | 
			
		||||
		.substring(0, 32); // Use first 32 chars for storage efficiency
 | 
			
		||||
 | 
			
		||||
	return fingerprint;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Check for suspicious activity patterns
 | 
			
		||||
 */
 | 
			
		||||
async function check_suspicious_activity(
 | 
			
		||||
	user_id,
 | 
			
		||||
	_ip_address,
 | 
			
		||||
	_device_fingerprint,
 | 
			
		||||
) {
 | 
			
		||||
	try {
 | 
			
		||||
		// Check for multiple sessions from different IPs in short time
 | 
			
		||||
		const recent_sessions = await prisma.user_sessions.findMany({
 | 
			
		||||
			where: {
 | 
			
		||||
				user_id: user_id,
 | 
			
		||||
				created_at: {
 | 
			
		||||
					gte: new Date(Date.now() - 24 * 60 * 60 * 1000), // Last 24 hours
 | 
			
		||||
				},
 | 
			
		||||
				is_revoked: false,
 | 
			
		||||
			},
 | 
			
		||||
			select: {
 | 
			
		||||
				ip_address: true,
 | 
			
		||||
				device_fingerprint: true,
 | 
			
		||||
				created_at: true,
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		// Count unique IPs and devices
 | 
			
		||||
		const unique_ips = new Set(recent_sessions.map((s) => s.ip_address));
 | 
			
		||||
		const unique_devices = new Set(
 | 
			
		||||
			recent_sessions.map((s) => s.device_fingerprint),
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		// Flag as suspicious if more than threshold different IPs or devices in 24h
 | 
			
		||||
		if (
 | 
			
		||||
			unique_ips.size > TFA_SUSPICIOUS_ACTIVITY_THRESHOLD ||
 | 
			
		||||
			unique_devices.size > TFA_SUSPICIOUS_ACTIVITY_THRESHOLD
 | 
			
		||||
		) {
 | 
			
		||||
			console.warn(
 | 
			
		||||
				`Suspicious activity detected for user ${user_id}: ${unique_ips.size} IPs, ${unique_devices.size} devices`,
 | 
			
		||||
			);
 | 
			
		||||
			return true;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return false;
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error("Error checking suspicious activity:", error);
 | 
			
		||||
		return false;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Create a new session for user
 | 
			
		||||
 */
 | 
			
		||||
async function create_session(user_id, ip_address, user_agent) {
 | 
			
		||||
async function create_session(
 | 
			
		||||
	user_id,
 | 
			
		||||
	ip_address,
 | 
			
		||||
	user_agent,
 | 
			
		||||
	remember_me = false,
 | 
			
		||||
	req = null,
 | 
			
		||||
) {
 | 
			
		||||
	try {
 | 
			
		||||
		const session_id = crypto.randomUUID();
 | 
			
		||||
		const refresh_token = generate_refresh_token();
 | 
			
		||||
		const access_token = generate_access_token(user_id, session_id);
 | 
			
		||||
 | 
			
		||||
		const expires_at = parse_expiration(JWT_REFRESH_EXPIRES_IN);
 | 
			
		||||
		// Generate device fingerprint if request is available
 | 
			
		||||
		const device_fingerprint = req ? generate_device_fingerprint(req) : null;
 | 
			
		||||
 | 
			
		||||
		// Check for suspicious activity
 | 
			
		||||
		if (device_fingerprint) {
 | 
			
		||||
			const is_suspicious = await check_suspicious_activity(
 | 
			
		||||
				user_id,
 | 
			
		||||
				ip_address,
 | 
			
		||||
				device_fingerprint,
 | 
			
		||||
			);
 | 
			
		||||
			if (is_suspicious) {
 | 
			
		||||
				console.warn(
 | 
			
		||||
					`Suspicious activity detected for user ${user_id}, session creation may be restricted`,
 | 
			
		||||
				);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Check session limits for remember me
 | 
			
		||||
		if (remember_me) {
 | 
			
		||||
			const existing_remember_sessions = await prisma.user_sessions.count({
 | 
			
		||||
				where: {
 | 
			
		||||
					user_id: user_id,
 | 
			
		||||
					tfa_remember_me: true,
 | 
			
		||||
					is_revoked: false,
 | 
			
		||||
					expires_at: { gt: new Date() },
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			// Limit remember me sessions per user
 | 
			
		||||
			if (existing_remember_sessions >= TFA_MAX_REMEMBER_SESSIONS) {
 | 
			
		||||
				throw new Error(
 | 
			
		||||
					"Maximum number of remembered devices reached. Please revoke an existing session first.",
 | 
			
		||||
				);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Use longer expiration for remember me sessions
 | 
			
		||||
		const expires_at = remember_me
 | 
			
		||||
			? parse_expiration(TFA_REMEMBER_ME_EXPIRES_IN)
 | 
			
		||||
			: parse_expiration(JWT_REFRESH_EXPIRES_IN);
 | 
			
		||||
 | 
			
		||||
		// Calculate TFA bypass until date for remember me sessions
 | 
			
		||||
		const tfa_bypass_until = remember_me
 | 
			
		||||
			? parse_expiration(TFA_REMEMBER_ME_EXPIRES_IN)
 | 
			
		||||
			: null;
 | 
			
		||||
 | 
			
		||||
		// Store session in database
 | 
			
		||||
		await prisma.user_sessions.create({
 | 
			
		||||
@@ -90,8 +220,13 @@ async function create_session(user_id, ip_address, user_agent) {
 | 
			
		||||
				access_token_hash: hash_token(access_token),
 | 
			
		||||
				ip_address: ip_address || null,
 | 
			
		||||
				user_agent: user_agent || null,
 | 
			
		||||
				device_fingerprint: device_fingerprint,
 | 
			
		||||
				last_login_ip: ip_address || null,
 | 
			
		||||
				last_activity: new Date(),
 | 
			
		||||
				expires_at: expires_at,
 | 
			
		||||
				tfa_remember_me: remember_me,
 | 
			
		||||
				tfa_bypass_until: tfa_bypass_until,
 | 
			
		||||
				login_count: 1,
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
@@ -100,6 +235,7 @@ async function create_session(user_id, ip_address, user_agent) {
 | 
			
		||||
			access_token,
 | 
			
		||||
			refresh_token,
 | 
			
		||||
			expires_at,
 | 
			
		||||
			tfa_bypass_until,
 | 
			
		||||
		};
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error("Error creating session:", error);
 | 
			
		||||
@@ -299,6 +435,8 @@ async function get_user_sessions(user_id) {
 | 
			
		||||
				last_activity: true,
 | 
			
		||||
				created_at: true,
 | 
			
		||||
				expires_at: true,
 | 
			
		||||
				tfa_remember_me: true,
 | 
			
		||||
				tfa_bypass_until: true,
 | 
			
		||||
			},
 | 
			
		||||
			orderBy: { last_activity: "desc" },
 | 
			
		||||
		});
 | 
			
		||||
@@ -308,6 +446,42 @@ async function get_user_sessions(user_id) {
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Check if TFA is bypassed for a session
 | 
			
		||||
 */
 | 
			
		||||
async function is_tfa_bypassed(session_id) {
 | 
			
		||||
	try {
 | 
			
		||||
		const session = await prisma.user_sessions.findUnique({
 | 
			
		||||
			where: { id: session_id },
 | 
			
		||||
			select: {
 | 
			
		||||
				tfa_remember_me: true,
 | 
			
		||||
				tfa_bypass_until: true,
 | 
			
		||||
				is_revoked: true,
 | 
			
		||||
				expires_at: true,
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		if (!session) {
 | 
			
		||||
			return false;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Check if session is still valid
 | 
			
		||||
		if (session.is_revoked || new Date() > session.expires_at) {
 | 
			
		||||
			return false;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Check if TFA is bypassed and still within bypass period
 | 
			
		||||
		if (session.tfa_remember_me && session.tfa_bypass_until) {
 | 
			
		||||
			return new Date() < session.tfa_bypass_until;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return false;
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error("Error checking TFA bypass:", error);
 | 
			
		||||
		return false;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
	create_session,
 | 
			
		||||
	validate_session,
 | 
			
		||||
@@ -317,6 +491,9 @@ module.exports = {
 | 
			
		||||
	revoke_all_user_sessions,
 | 
			
		||||
	cleanup_expired_sessions,
 | 
			
		||||
	get_user_sessions,
 | 
			
		||||
	is_tfa_bypassed,
 | 
			
		||||
	generate_device_fingerprint,
 | 
			
		||||
	check_suspicious_activity,
 | 
			
		||||
	generate_access_token,
 | 
			
		||||
	INACTIVITY_TIMEOUT_MINUTES,
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -19,6 +19,7 @@ PatchMon is a containerised application that monitors system patches and updates
 | 
			
		||||
- `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.
 | 
			
		||||
- `edge`: The latest development build with the most recent features and fixes. This tag may often be unstable and is intended only for testing and development purposes.
 | 
			
		||||
 | 
			
		||||
These tags are available for both backend and frontend images as they are versioned together.
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -8,19 +8,94 @@ log() {
 | 
			
		||||
    echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" >&2
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Copy files from agents_backup to agents if agents directory is empty
 | 
			
		||||
if [ -d "/app/agents" ] && [ -z "$(ls -A /app/agents 2>/dev/null)" ]; then
 | 
			
		||||
    if [ -d "/app/agents_backup" ]; then
 | 
			
		||||
        log "Agents directory is empty, copying from backup..."
 | 
			
		||||
        cp -r /app/agents_backup/* /app/agents/
 | 
			
		||||
# Function to extract version from agent script
 | 
			
		||||
get_agent_version() {
 | 
			
		||||
    local file="$1"
 | 
			
		||||
    if [ -f "$file" ]; then
 | 
			
		||||
        grep -m 1 '^AGENT_VERSION=' "$file" | cut -d'"' -f2 2>/dev/null || echo "0.0.0"
 | 
			
		||||
    else
 | 
			
		||||
        log "Warning: agents_backup directory not found"
 | 
			
		||||
        echo "0.0.0"
 | 
			
		||||
    fi
 | 
			
		||||
else
 | 
			
		||||
    log "Agents directory already contains files, skipping copy"
 | 
			
		||||
fi
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
log "Starting PatchMon Backend (${NODE_ENV:-production})..."
 | 
			
		||||
# Function to compare versions (returns 0 if $1 > $2)
 | 
			
		||||
version_greater() {
 | 
			
		||||
    # Use sort -V for version comparison
 | 
			
		||||
    test "$(printf '%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" && test "$1" != "$2"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Check and update agent files if necessary
 | 
			
		||||
update_agents() {
 | 
			
		||||
    local backup_agent="/app/agents_backup/patchmon-agent.sh"
 | 
			
		||||
    local current_agent="/app/agents/patchmon-agent.sh"
 | 
			
		||||
    
 | 
			
		||||
    # Check if agents directory exists
 | 
			
		||||
    if [ ! -d "/app/agents" ]; then
 | 
			
		||||
        log "ERROR: /app/agents directory not found"
 | 
			
		||||
        return 1
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Check if backup exists
 | 
			
		||||
    if [ ! -d "/app/agents_backup" ]; then
 | 
			
		||||
        log "WARNING: agents_backup directory not found, skipping agent update"
 | 
			
		||||
        return 0
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Get versions
 | 
			
		||||
    local backup_version=$(get_agent_version "$backup_agent")
 | 
			
		||||
    local current_version=$(get_agent_version "$current_agent")
 | 
			
		||||
    
 | 
			
		||||
    log "Agent version check:"
 | 
			
		||||
    log "  Image version: ${backup_version}"
 | 
			
		||||
    log "  Volume version: ${current_version}"
 | 
			
		||||
    
 | 
			
		||||
    # Determine if update is needed
 | 
			
		||||
    local needs_update=0
 | 
			
		||||
    
 | 
			
		||||
    # Case 1: No agents in volume (first time setup)
 | 
			
		||||
    if [ -z "$(find /app/agents -maxdepth 1 -type f -name '*.sh' 2>/dev/null | head -n 1)" ]; then
 | 
			
		||||
        log "Agents directory is empty - performing initial copy"
 | 
			
		||||
        needs_update=1
 | 
			
		||||
    # Case 2: Backup version is newer
 | 
			
		||||
    elif version_greater "$backup_version" "$current_version"; then
 | 
			
		||||
        log "Newer agent version available (${backup_version} > ${current_version})"
 | 
			
		||||
        needs_update=1
 | 
			
		||||
    else
 | 
			
		||||
        log "Agents are up to date"
 | 
			
		||||
        needs_update=0
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Perform update if needed
 | 
			
		||||
    if [ $needs_update -eq 1 ]; then
 | 
			
		||||
        log "Updating agents to version ${backup_version}..."
 | 
			
		||||
        
 | 
			
		||||
        # Create backup of existing agents if they exist
 | 
			
		||||
        if [ -f "$current_agent" ]; then
 | 
			
		||||
            local backup_timestamp=$(date +%Y%m%d_%H%M%S)
 | 
			
		||||
            local backup_name="/app/agents/patchmon-agent.sh.backup.${backup_timestamp}"
 | 
			
		||||
            cp "$current_agent" "$backup_name" 2>/dev/null || true
 | 
			
		||||
            log "Previous agent backed up to: $(basename $backup_name)"
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        # Copy new agents
 | 
			
		||||
        cp -r /app/agents_backup/* /app/agents/
 | 
			
		||||
        
 | 
			
		||||
        # Verify update
 | 
			
		||||
        local new_version=$(get_agent_version "$current_agent")
 | 
			
		||||
        if [ "$new_version" = "$backup_version" ]; then
 | 
			
		||||
            log "✅ Agents successfully updated to version ${new_version}"
 | 
			
		||||
        else
 | 
			
		||||
            log "⚠️ Warning: Agent update may have failed (expected: ${backup_version}, got: ${new_version})"
 | 
			
		||||
        fi
 | 
			
		||||
    fi
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Main execution
 | 
			
		||||
log "PatchMon Backend Container Starting..."
 | 
			
		||||
log "Environment: ${NODE_ENV:-production}"
 | 
			
		||||
 | 
			
		||||
# Update agents (version-aware)
 | 
			
		||||
update_agents
 | 
			
		||||
 | 
			
		||||
log "Running database migrations..."
 | 
			
		||||
npx prisma migrate deploy
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
{
 | 
			
		||||
	"name": "patchmon-frontend",
 | 
			
		||||
	"private": true,
 | 
			
		||||
	"version": "1.2.7",
 | 
			
		||||
	"version": "1.2.8",
 | 
			
		||||
	"license": "AGPL-3.0",
 | 
			
		||||
	"type": "module",
 | 
			
		||||
	"scripts": {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import { lazy, Suspense } from "react";
 | 
			
		||||
import { Route, Routes } from "react-router-dom";
 | 
			
		||||
import FirstTimeAdminSetup from "./components/FirstTimeAdminSetup";
 | 
			
		||||
import Layout from "./components/Layout";
 | 
			
		||||
@@ -8,23 +9,42 @@ import { isAuthPhase } from "./constants/authPhases";
 | 
			
		||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
 | 
			
		||||
import { ThemeProvider } from "./contexts/ThemeContext";
 | 
			
		||||
import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
 | 
			
		||||
import Dashboard from "./pages/Dashboard";
 | 
			
		||||
import HostDetail from "./pages/HostDetail";
 | 
			
		||||
import Hosts from "./pages/Hosts";
 | 
			
		||||
import Login from "./pages/Login";
 | 
			
		||||
import PackageDetail from "./pages/PackageDetail";
 | 
			
		||||
import Packages from "./pages/Packages";
 | 
			
		||||
import Profile from "./pages/Profile";
 | 
			
		||||
import Repositories from "./pages/Repositories";
 | 
			
		||||
import RepositoryDetail from "./pages/RepositoryDetail";
 | 
			
		||||
import AlertChannels from "./pages/settings/AlertChannels";
 | 
			
		||||
import Integrations from "./pages/settings/Integrations";
 | 
			
		||||
import Notifications from "./pages/settings/Notifications";
 | 
			
		||||
import PatchManagement from "./pages/settings/PatchManagement";
 | 
			
		||||
import SettingsAgentConfig from "./pages/settings/SettingsAgentConfig";
 | 
			
		||||
import SettingsHostGroups from "./pages/settings/SettingsHostGroups";
 | 
			
		||||
import SettingsServerConfig from "./pages/settings/SettingsServerConfig";
 | 
			
		||||
import SettingsUsers from "./pages/settings/SettingsUsers";
 | 
			
		||||
 | 
			
		||||
// Lazy load pages
 | 
			
		||||
const Dashboard = lazy(() => import("./pages/Dashboard"));
 | 
			
		||||
const HostDetail = lazy(() => import("./pages/HostDetail"));
 | 
			
		||||
const Hosts = lazy(() => import("./pages/Hosts"));
 | 
			
		||||
const Login = lazy(() => import("./pages/Login"));
 | 
			
		||||
const PackageDetail = lazy(() => import("./pages/PackageDetail"));
 | 
			
		||||
const Packages = lazy(() => import("./pages/Packages"));
 | 
			
		||||
const Profile = lazy(() => import("./pages/Profile"));
 | 
			
		||||
const Queue = lazy(() => import("./pages/Queue"));
 | 
			
		||||
const Repositories = lazy(() => import("./pages/Repositories"));
 | 
			
		||||
const RepositoryDetail = lazy(() => import("./pages/RepositoryDetail"));
 | 
			
		||||
const AlertChannels = lazy(() => import("./pages/settings/AlertChannels"));
 | 
			
		||||
const Integrations = lazy(() => import("./pages/settings/Integrations"));
 | 
			
		||||
const Notifications = lazy(() => import("./pages/settings/Notifications"));
 | 
			
		||||
const PatchManagement = lazy(() => import("./pages/settings/PatchManagement"));
 | 
			
		||||
const SettingsAgentConfig = lazy(
 | 
			
		||||
	() => import("./pages/settings/SettingsAgentConfig"),
 | 
			
		||||
);
 | 
			
		||||
const SettingsHostGroups = lazy(
 | 
			
		||||
	() => import("./pages/settings/SettingsHostGroups"),
 | 
			
		||||
);
 | 
			
		||||
const SettingsServerConfig = lazy(
 | 
			
		||||
	() => import("./pages/settings/SettingsServerConfig"),
 | 
			
		||||
);
 | 
			
		||||
const SettingsUsers = lazy(() => import("./pages/settings/SettingsUsers"));
 | 
			
		||||
 | 
			
		||||
// Loading fallback component
 | 
			
		||||
const LoadingFallback = () => (
 | 
			
		||||
	<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center">
 | 
			
		||||
		<div className="text-center">
 | 
			
		||||
			<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
 | 
			
		||||
			<p className="text-secondary-600 dark:text-secondary-300">Loading...</p>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
function AppRoutes() {
 | 
			
		||||
	const { needsFirstTimeSetup, authPhase, isAuthenticated } = useAuth();
 | 
			
		||||
@@ -53,285 +73,297 @@ function AppRoutes() {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<Routes>
 | 
			
		||||
			<Route path="/login" element={<Login />} />
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_view_dashboard">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<Dashboard />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/hosts"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_view_hosts">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<Hosts />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/hosts/:hostId"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_view_hosts">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<HostDetail />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/packages"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_view_packages">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<Packages />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/repositories"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_view_hosts">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<Repositories />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/repositories/:repositoryId"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_view_hosts">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<RepositoryDetail />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/users"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_view_users">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<SettingsUsers />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/permissions"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<SettingsUsers />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/settings"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<SettingsServerConfig />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/settings/users"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_view_users">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<SettingsUsers />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/settings/roles"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<SettingsUsers />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/settings/profile"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute>
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<SettingsLayout>
 | 
			
		||||
								<Profile />
 | 
			
		||||
							</SettingsLayout>
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/settings/host-groups"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<SettingsHostGroups />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/settings/notifications"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<SettingsLayout>
 | 
			
		||||
								<Notifications />
 | 
			
		||||
							</SettingsLayout>
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/settings/agent-config"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<SettingsAgentConfig />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/settings/agent-config/management"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<SettingsAgentConfig />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/settings/server-config"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<SettingsServerConfig />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/settings/server-config/version"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<SettingsServerConfig />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/settings/alert-channels"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<SettingsLayout>
 | 
			
		||||
								<AlertChannels />
 | 
			
		||||
							</SettingsLayout>
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/settings/integrations"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<Integrations />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/settings/patch-management"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<PatchManagement />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/settings/server-url"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<SettingsServerConfig />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/settings/server-version"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<SettingsServerConfig />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/settings/branding"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<SettingsServerConfig />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/settings/agent-version"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<SettingsAgentConfig />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/options"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_manage_hosts">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<SettingsHostGroups />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/packages/:packageId"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_view_packages">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<PackageDetail />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
		</Routes>
 | 
			
		||||
		<Suspense fallback={<LoadingFallback />}>
 | 
			
		||||
			<Routes>
 | 
			
		||||
				<Route path="/login" element={<Login />} />
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_view_dashboard">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<Dashboard />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/hosts"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_view_hosts">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<Hosts />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/hosts/:hostId"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_view_hosts">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<HostDetail />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/packages"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_view_packages">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<Packages />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/repositories"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_view_hosts">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<Repositories />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/repositories/:repositoryId"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_view_hosts">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<RepositoryDetail />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/queue"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_view_hosts">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<Queue />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/users"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_view_users">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<SettingsUsers />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/permissions"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<SettingsUsers />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/settings"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<SettingsServerConfig />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/settings/users"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_view_users">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<SettingsUsers />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/settings/roles"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<SettingsUsers />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/settings/profile"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute>
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<SettingsLayout>
 | 
			
		||||
									<Profile />
 | 
			
		||||
								</SettingsLayout>
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/settings/host-groups"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<SettingsHostGroups />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/settings/notifications"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<SettingsLayout>
 | 
			
		||||
									<Notifications />
 | 
			
		||||
								</SettingsLayout>
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/settings/agent-config"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<SettingsAgentConfig />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/settings/agent-config/management"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<SettingsAgentConfig />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/settings/server-config"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<SettingsServerConfig />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/settings/server-config/version"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<SettingsServerConfig />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/settings/alert-channels"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<SettingsLayout>
 | 
			
		||||
									<AlertChannels />
 | 
			
		||||
								</SettingsLayout>
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/settings/integrations"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<Integrations />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/settings/patch-management"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<PatchManagement />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/settings/server-url"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<SettingsServerConfig />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/settings/server-version"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<SettingsServerConfig />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/settings/branding"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<SettingsServerConfig />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/settings/agent-version"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<SettingsAgentConfig />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/options"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_manage_hosts">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<SettingsHostGroups />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/packages/:packageId"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_view_packages">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<PackageDetail />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
			</Routes>
 | 
			
		||||
		</Suspense>
 | 
			
		||||
	);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@@ -1,11 +1,16 @@
 | 
			
		||||
import { useQuery } from "@tanstack/react-query";
 | 
			
		||||
import { useEffect } from "react";
 | 
			
		||||
import { isAuthReady } from "../constants/authPhases";
 | 
			
		||||
import { useAuth } from "../contexts/AuthContext";
 | 
			
		||||
import { settingsAPI } from "../utils/api";
 | 
			
		||||
 | 
			
		||||
const LogoProvider = ({ children }) => {
 | 
			
		||||
	const { authPhase, isAuthenticated } = useAuth();
 | 
			
		||||
 | 
			
		||||
	const { data: settings } = useQuery({
 | 
			
		||||
		queryKey: ["settings"],
 | 
			
		||||
		queryFn: () => settingsAPI.get().then((res) => res.data),
 | 
			
		||||
		enabled: isAuthReady(authPhase, isAuthenticated()),
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	useEffect(() => {
 | 
			
		||||
 
 | 
			
		||||
@@ -82,6 +82,7 @@ const SettingsLayout = ({ children }) => {
 | 
			
		||||
						name: "Alert Channels",
 | 
			
		||||
						href: "/settings/alert-channels",
 | 
			
		||||
						icon: Bell,
 | 
			
		||||
						comingSoon: true,
 | 
			
		||||
					},
 | 
			
		||||
					{
 | 
			
		||||
						name: "Notifications",
 | 
			
		||||
@@ -118,7 +119,6 @@ const SettingsLayout = ({ children }) => {
 | 
			
		||||
						name: "Integrations",
 | 
			
		||||
						href: "/settings/integrations",
 | 
			
		||||
						icon: Wrench,
 | 
			
		||||
						comingSoon: true,
 | 
			
		||||
					},
 | 
			
		||||
				],
 | 
			
		||||
			});
 | 
			
		||||
 
 | 
			
		||||
@@ -54,7 +54,7 @@ const UsersTab = () => {
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Update user mutation
 | 
			
		||||
	const updateUserMutation = useMutation({
 | 
			
		||||
	const _updateUserMutation = useMutation({
 | 
			
		||||
		mutationFn: ({ id, data }) => adminUsersAPI.update(id, data),
 | 
			
		||||
		onSuccess: () => {
 | 
			
		||||
			queryClient.invalidateQueries(["users"]);
 | 
			
		||||
@@ -319,9 +319,8 @@ const UsersTab = () => {
 | 
			
		||||
					user={editingUser}
 | 
			
		||||
					isOpen={!!editingUser}
 | 
			
		||||
					onClose={() => setEditingUser(null)}
 | 
			
		||||
					onUserUpdated={() => {
 | 
			
		||||
						queryClient.invalidateQueries(["users"]);
 | 
			
		||||
					}}
 | 
			
		||||
					onUpdateUser={updateUserMutation.mutate}
 | 
			
		||||
					isLoading={updateUserMutation.isPending}
 | 
			
		||||
					roles={roles}
 | 
			
		||||
				/>
 | 
			
		||||
			)}
 | 
			
		||||
@@ -591,7 +590,14 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Edit User Modal Component
 | 
			
		||||
const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
 | 
			
		||||
const EditUserModal = ({
 | 
			
		||||
	user,
 | 
			
		||||
	isOpen,
 | 
			
		||||
	onClose,
 | 
			
		||||
	onUpdateUser,
 | 
			
		||||
	isLoading,
 | 
			
		||||
	roles,
 | 
			
		||||
}) => {
 | 
			
		||||
	const editUsernameId = useId();
 | 
			
		||||
	const editEmailId = useId();
 | 
			
		||||
	const editFirstNameId = useId();
 | 
			
		||||
@@ -607,7 +613,6 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
 | 
			
		||||
		role: user?.role || "user",
 | 
			
		||||
		is_active: user?.is_active ?? true,
 | 
			
		||||
	});
 | 
			
		||||
	const [isLoading, setIsLoading] = useState(false);
 | 
			
		||||
	const [error, setError] = useState("");
 | 
			
		||||
	const [success, setSuccess] = useState(false);
 | 
			
		||||
 | 
			
		||||
@@ -635,22 +640,18 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
 | 
			
		||||
 | 
			
		||||
	const handleSubmit = async (e) => {
 | 
			
		||||
		e.preventDefault();
 | 
			
		||||
		setIsLoading(true);
 | 
			
		||||
		setError("");
 | 
			
		||||
		setSuccess(false);
 | 
			
		||||
 | 
			
		||||
		try {
 | 
			
		||||
			await adminUsersAPI.update(user.id, formData);
 | 
			
		||||
			await onUpdateUser({ id: user.id, data: 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 {
 | 
			
		||||
			setIsLoading(false);
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -128,12 +128,14 @@ const VersionUpdateTab = () => {
 | 
			
		||||
								<span className="text-lg font-mono text-secondary-900 dark:text-white">
 | 
			
		||||
									{versionInfo.github.latestRelease.tagName}
 | 
			
		||||
								</span>
 | 
			
		||||
								<div className="text-xs text-secondary-500 dark:text-secondary-400">
 | 
			
		||||
									Published:{" "}
 | 
			
		||||
									{new Date(
 | 
			
		||||
										versionInfo.github.latestRelease.publishedAt,
 | 
			
		||||
									).toLocaleDateString()}
 | 
			
		||||
								</div>
 | 
			
		||||
								{versionInfo.github.latestRelease.publishedAt && (
 | 
			
		||||
									<div className="text-xs text-secondary-500 dark:text-secondary-400">
 | 
			
		||||
										Published:{" "}
 | 
			
		||||
										{new Date(
 | 
			
		||||
											versionInfo.github.latestRelease.publishedAt,
 | 
			
		||||
										).toLocaleDateString()}
 | 
			
		||||
									</div>
 | 
			
		||||
								)}
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					)}
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,8 @@ import {
 | 
			
		||||
	Chart as ChartJS,
 | 
			
		||||
	Legend,
 | 
			
		||||
	LinearScale,
 | 
			
		||||
	LineElement,
 | 
			
		||||
	PointElement,
 | 
			
		||||
	Title,
 | 
			
		||||
	Tooltip,
 | 
			
		||||
} from "chart.js";
 | 
			
		||||
@@ -23,7 +25,7 @@ import {
 | 
			
		||||
	WifiOff,
 | 
			
		||||
} from "lucide-react";
 | 
			
		||||
import { useEffect, useState } from "react";
 | 
			
		||||
import { Bar, Doughnut, Pie } from "react-chartjs-2";
 | 
			
		||||
import { Bar, Doughnut, Line, Pie } from "react-chartjs-2";
 | 
			
		||||
import { useNavigate } from "react-router-dom";
 | 
			
		||||
import DashboardSettingsModal from "../components/DashboardSettingsModal";
 | 
			
		||||
import { useAuth } from "../contexts/AuthContext";
 | 
			
		||||
@@ -43,12 +45,16 @@ ChartJS.register(
 | 
			
		||||
	CategoryScale,
 | 
			
		||||
	LinearScale,
 | 
			
		||||
	BarElement,
 | 
			
		||||
	LineElement,
 | 
			
		||||
	PointElement,
 | 
			
		||||
	Title,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const Dashboard = () => {
 | 
			
		||||
	const [showSettingsModal, setShowSettingsModal] = useState(false);
 | 
			
		||||
	const [cardPreferences, setCardPreferences] = useState([]);
 | 
			
		||||
	const [packageTrendsPeriod, setPackageTrendsPeriod] = useState("1"); // days
 | 
			
		||||
	const [packageTrendsHost, setPackageTrendsHost] = useState("all"); // host filter
 | 
			
		||||
	const navigate = useNavigate();
 | 
			
		||||
	const { isDark } = useTheme();
 | 
			
		||||
	const { user } = useAuth();
 | 
			
		||||
@@ -91,7 +97,7 @@ const Dashboard = () => {
 | 
			
		||||
		navigate("/repositories");
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const handleOSDistributionClick = () => {
 | 
			
		||||
	const _handleOSDistributionClick = () => {
 | 
			
		||||
		navigate("/hosts?showFilters=true", { replace: true });
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
@@ -99,7 +105,7 @@ const Dashboard = () => {
 | 
			
		||||
		navigate("/hosts?filter=needsUpdates", { replace: true });
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const handlePackagePriorityClick = () => {
 | 
			
		||||
	const _handlePackagePriorityClick = () => {
 | 
			
		||||
		navigate("/packages?filter=security");
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
@@ -144,8 +150,8 @@ const Dashboard = () => {
 | 
			
		||||
			// Map priority names to filter parameters
 | 
			
		||||
			if (priorityName.toLowerCase().includes("security")) {
 | 
			
		||||
				navigate("/packages?filter=security", { replace: true });
 | 
			
		||||
			} else if (priorityName.toLowerCase().includes("outdated")) {
 | 
			
		||||
				navigate("/packages?filter=outdated", { replace: true });
 | 
			
		||||
			} else if (priorityName.toLowerCase().includes("regular")) {
 | 
			
		||||
				navigate("/packages?filter=regular", { replace: true });
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
@@ -189,6 +195,26 @@ const Dashboard = () => {
 | 
			
		||||
		refetchOnWindowFocus: false, // Don't refetch when window regains focus
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Package trends data query
 | 
			
		||||
	const {
 | 
			
		||||
		data: packageTrendsData,
 | 
			
		||||
		isLoading: packageTrendsLoading,
 | 
			
		||||
		error: _packageTrendsError,
 | 
			
		||||
	} = useQuery({
 | 
			
		||||
		queryKey: ["packageTrends", packageTrendsPeriod, packageTrendsHost],
 | 
			
		||||
		queryFn: () => {
 | 
			
		||||
			const params = {
 | 
			
		||||
				days: packageTrendsPeriod,
 | 
			
		||||
			};
 | 
			
		||||
			if (packageTrendsHost !== "all") {
 | 
			
		||||
				params.hostId = packageTrendsHost;
 | 
			
		||||
			}
 | 
			
		||||
			return dashboardAPI.getPackageTrends(params).then((res) => res.data);
 | 
			
		||||
		},
 | 
			
		||||
		staleTime: 5 * 60 * 1000, // 5 minutes
 | 
			
		||||
		refetchOnWindowFocus: false,
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Fetch recent users (permission protected server-side)
 | 
			
		||||
	const { data: recentUsers } = useQuery({
 | 
			
		||||
		queryKey: ["dashboardRecentUsers"],
 | 
			
		||||
@@ -299,6 +325,8 @@ const Dashboard = () => {
 | 
			
		||||
			].includes(cardId)
 | 
			
		||||
		) {
 | 
			
		||||
			return "charts";
 | 
			
		||||
		} else if (["packageTrends"].includes(cardId)) {
 | 
			
		||||
			return "charts";
 | 
			
		||||
		} else if (["erroredHosts", "quickStats"].includes(cardId)) {
 | 
			
		||||
			return "fullwidth";
 | 
			
		||||
		}
 | 
			
		||||
@@ -312,6 +340,8 @@ const Dashboard = () => {
 | 
			
		||||
				return "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4";
 | 
			
		||||
			case "charts":
 | 
			
		||||
				return "grid grid-cols-1 lg:grid-cols-3 gap-6";
 | 
			
		||||
			case "widecharts":
 | 
			
		||||
				return "grid grid-cols-1 lg:grid-cols-3 gap-6";
 | 
			
		||||
			case "fullwidth":
 | 
			
		||||
				return "space-y-6";
 | 
			
		||||
			default:
 | 
			
		||||
@@ -651,17 +681,7 @@ const Dashboard = () => {
 | 
			
		||||
 | 
			
		||||
			case "osDistribution":
 | 
			
		||||
				return (
 | 
			
		||||
					<button
 | 
			
		||||
						type="button"
 | 
			
		||||
						className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
 | 
			
		||||
						onClick={handleOSDistributionClick}
 | 
			
		||||
						onKeyDown={(e) => {
 | 
			
		||||
							if (e.key === "Enter" || e.key === " ") {
 | 
			
		||||
								e.preventDefault();
 | 
			
		||||
								handleOSDistributionClick();
 | 
			
		||||
							}
 | 
			
		||||
						}}
 | 
			
		||||
					>
 | 
			
		||||
					<div className="card p-6 w-full">
 | 
			
		||||
						<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
 | 
			
		||||
							OS Distribution
 | 
			
		||||
						</h3>
 | 
			
		||||
@@ -670,22 +690,12 @@ const Dashboard = () => {
 | 
			
		||||
								<Pie data={osChartData} options={chartOptions} />
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					</button>
 | 
			
		||||
					</div>
 | 
			
		||||
				);
 | 
			
		||||
 | 
			
		||||
			case "osDistributionDoughnut":
 | 
			
		||||
				return (
 | 
			
		||||
					<button
 | 
			
		||||
						type="button"
 | 
			
		||||
						className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
 | 
			
		||||
						onClick={handleOSDistributionClick}
 | 
			
		||||
						onKeyDown={(e) => {
 | 
			
		||||
							if (e.key === "Enter" || e.key === " ") {
 | 
			
		||||
								e.preventDefault();
 | 
			
		||||
								handleOSDistributionClick();
 | 
			
		||||
							}
 | 
			
		||||
						}}
 | 
			
		||||
					>
 | 
			
		||||
					<div className="card p-6 w-full">
 | 
			
		||||
						<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
 | 
			
		||||
							OS Distribution
 | 
			
		||||
						</h3>
 | 
			
		||||
@@ -694,29 +704,19 @@ const Dashboard = () => {
 | 
			
		||||
								<Doughnut data={osChartData} options={doughnutChartOptions} />
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					</button>
 | 
			
		||||
					</div>
 | 
			
		||||
				);
 | 
			
		||||
 | 
			
		||||
			case "osDistributionBar":
 | 
			
		||||
				return (
 | 
			
		||||
					<button
 | 
			
		||||
						type="button"
 | 
			
		||||
						className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
 | 
			
		||||
						onClick={handleOSDistributionClick}
 | 
			
		||||
						onKeyDown={(e) => {
 | 
			
		||||
							if (e.key === "Enter" || e.key === " ") {
 | 
			
		||||
								e.preventDefault();
 | 
			
		||||
								handleOSDistributionClick();
 | 
			
		||||
							}
 | 
			
		||||
						}}
 | 
			
		||||
					>
 | 
			
		||||
					<div className="card p-6 w-full">
 | 
			
		||||
						<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
 | 
			
		||||
							OS Distribution
 | 
			
		||||
						</h3>
 | 
			
		||||
						<div className="h-64">
 | 
			
		||||
							<Bar data={osBarChartData} options={barChartOptions} />
 | 
			
		||||
						</div>
 | 
			
		||||
					</button>
 | 
			
		||||
					</div>
 | 
			
		||||
				);
 | 
			
		||||
 | 
			
		||||
			case "updateStatus":
 | 
			
		||||
@@ -748,19 +748,9 @@ const Dashboard = () => {
 | 
			
		||||
 | 
			
		||||
			case "packagePriority":
 | 
			
		||||
				return (
 | 
			
		||||
					<button
 | 
			
		||||
						type="button"
 | 
			
		||||
						className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
 | 
			
		||||
						onClick={handlePackagePriorityClick}
 | 
			
		||||
						onKeyDown={(e) => {
 | 
			
		||||
							if (e.key === "Enter" || e.key === " ") {
 | 
			
		||||
								e.preventDefault();
 | 
			
		||||
								handlePackagePriorityClick();
 | 
			
		||||
							}
 | 
			
		||||
						}}
 | 
			
		||||
					>
 | 
			
		||||
					<div className="card p-6 w-full">
 | 
			
		||||
						<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
 | 
			
		||||
							Package Priority
 | 
			
		||||
							Outdated Packages by Priority
 | 
			
		||||
						</h3>
 | 
			
		||||
						<div className="h-64 w-full flex items-center justify-center">
 | 
			
		||||
							<div className="w-full h-full max-w-sm">
 | 
			
		||||
@@ -770,7 +760,72 @@ const Dashboard = () => {
 | 
			
		||||
								/>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					</button>
 | 
			
		||||
					</div>
 | 
			
		||||
				);
 | 
			
		||||
 | 
			
		||||
			case "packageTrends":
 | 
			
		||||
				return (
 | 
			
		||||
					<div className="card p-6 w-full">
 | 
			
		||||
						<div className="flex items-center justify-between mb-4">
 | 
			
		||||
							<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
 | 
			
		||||
								Package Trends Over Time
 | 
			
		||||
							</h3>
 | 
			
		||||
							<div className="flex items-center gap-3">
 | 
			
		||||
								{/* Period Selector */}
 | 
			
		||||
								<select
 | 
			
		||||
									value={packageTrendsPeriod}
 | 
			
		||||
									onChange={(e) => setPackageTrendsPeriod(e.target.value)}
 | 
			
		||||
									className="px-3 py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
 | 
			
		||||
								>
 | 
			
		||||
									<option value="1">Last 24 hours</option>
 | 
			
		||||
									<option value="7">Last 7 days</option>
 | 
			
		||||
									<option value="30">Last 30 days</option>
 | 
			
		||||
									<option value="90">Last 90 days</option>
 | 
			
		||||
									<option value="180">Last 6 months</option>
 | 
			
		||||
									<option value="365">Last year</option>
 | 
			
		||||
								</select>
 | 
			
		||||
 | 
			
		||||
								{/* Host Selector */}
 | 
			
		||||
								<select
 | 
			
		||||
									value={packageTrendsHost}
 | 
			
		||||
									onChange={(e) => setPackageTrendsHost(e.target.value)}
 | 
			
		||||
									className="px-3 py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
 | 
			
		||||
								>
 | 
			
		||||
									<option value="all">All Hosts</option>
 | 
			
		||||
									{packageTrendsData?.hosts?.length > 0 ? (
 | 
			
		||||
										packageTrendsData.hosts.map((host) => (
 | 
			
		||||
											<option key={host.id} value={host.id}>
 | 
			
		||||
												{host.friendly_name || host.hostname}
 | 
			
		||||
											</option>
 | 
			
		||||
										))
 | 
			
		||||
									) : (
 | 
			
		||||
										<option disabled>
 | 
			
		||||
											{packageTrendsLoading
 | 
			
		||||
												? "Loading hosts..."
 | 
			
		||||
												: "No hosts available"}
 | 
			
		||||
										</option>
 | 
			
		||||
									)}
 | 
			
		||||
								</select>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
 | 
			
		||||
						<div className="h-64 w-full">
 | 
			
		||||
							{packageTrendsLoading ? (
 | 
			
		||||
								<div className="flex items-center justify-center h-full">
 | 
			
		||||
									<RefreshCw className="h-8 w-8 animate-spin text-primary-600" />
 | 
			
		||||
								</div>
 | 
			
		||||
							) : packageTrendsData?.chartData ? (
 | 
			
		||||
								<Line
 | 
			
		||||
									data={packageTrendsData.chartData}
 | 
			
		||||
									options={packageTrendsChartOptions}
 | 
			
		||||
								/>
 | 
			
		||||
							) : (
 | 
			
		||||
								<div className="flex items-center justify-center h-full text-secondary-500 dark:text-secondary-400">
 | 
			
		||||
									No data available
 | 
			
		||||
								</div>
 | 
			
		||||
							)}
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				);
 | 
			
		||||
 | 
			
		||||
			case "quickStats": {
 | 
			
		||||
@@ -1068,6 +1123,167 @@ const Dashboard = () => {
 | 
			
		||||
		onClick: handlePackagePriorityChartClick,
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const packageTrendsChartOptions = {
 | 
			
		||||
		responsive: true,
 | 
			
		||||
		maintainAspectRatio: false,
 | 
			
		||||
		plugins: {
 | 
			
		||||
			legend: {
 | 
			
		||||
				position: "top",
 | 
			
		||||
				labels: {
 | 
			
		||||
					color: isDark ? "#ffffff" : "#374151",
 | 
			
		||||
					font: {
 | 
			
		||||
						size: 12,
 | 
			
		||||
					},
 | 
			
		||||
					padding: 20,
 | 
			
		||||
					usePointStyle: true,
 | 
			
		||||
					pointStyle: "circle",
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			tooltip: {
 | 
			
		||||
				mode: "index",
 | 
			
		||||
				intersect: false,
 | 
			
		||||
				backgroundColor: isDark ? "#374151" : "#ffffff",
 | 
			
		||||
				titleColor: isDark ? "#ffffff" : "#374151",
 | 
			
		||||
				bodyColor: isDark ? "#ffffff" : "#374151",
 | 
			
		||||
				borderColor: isDark ? "#4B5563" : "#E5E7EB",
 | 
			
		||||
				borderWidth: 1,
 | 
			
		||||
				callbacks: {
 | 
			
		||||
					title: (context) => {
 | 
			
		||||
						const label = context[0].label;
 | 
			
		||||
 | 
			
		||||
						// Handle empty or invalid labels
 | 
			
		||||
						if (!label || typeof label !== "string") {
 | 
			
		||||
							return "Unknown Date";
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						// Format hourly labels (e.g., "2025-10-07T14" -> "Oct 7, 2:00 PM")
 | 
			
		||||
						if (label.includes("T")) {
 | 
			
		||||
							try {
 | 
			
		||||
								const date = new Date(`${label}:00:00`);
 | 
			
		||||
								// Check if date is valid
 | 
			
		||||
								if (isNaN(date.getTime())) {
 | 
			
		||||
									return label; // Return original label if date is invalid
 | 
			
		||||
								}
 | 
			
		||||
								return date.toLocaleDateString("en-US", {
 | 
			
		||||
									month: "short",
 | 
			
		||||
									day: "numeric",
 | 
			
		||||
									hour: "numeric",
 | 
			
		||||
									minute: "2-digit",
 | 
			
		||||
									hour12: true,
 | 
			
		||||
								});
 | 
			
		||||
							} catch (error) {
 | 
			
		||||
								return label; // Return original label if parsing fails
 | 
			
		||||
							}
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						// Format daily labels (e.g., "2025-10-07" -> "Oct 7")
 | 
			
		||||
						try {
 | 
			
		||||
							const date = new Date(label);
 | 
			
		||||
							// Check if date is valid
 | 
			
		||||
							if (isNaN(date.getTime())) {
 | 
			
		||||
								return label; // Return original label if date is invalid
 | 
			
		||||
							}
 | 
			
		||||
							return date.toLocaleDateString("en-US", {
 | 
			
		||||
								month: "short",
 | 
			
		||||
								day: "numeric",
 | 
			
		||||
							});
 | 
			
		||||
						} catch (error) {
 | 
			
		||||
							return label; // Return original label if parsing fails
 | 
			
		||||
						}
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		scales: {
 | 
			
		||||
			x: {
 | 
			
		||||
				display: true,
 | 
			
		||||
				title: {
 | 
			
		||||
					display: true,
 | 
			
		||||
					text: packageTrendsPeriod === "1" ? "Time (Hours)" : "Date",
 | 
			
		||||
					color: isDark ? "#ffffff" : "#374151",
 | 
			
		||||
				},
 | 
			
		||||
				ticks: {
 | 
			
		||||
					color: isDark ? "#ffffff" : "#374151",
 | 
			
		||||
					font: {
 | 
			
		||||
						size: 11,
 | 
			
		||||
					},
 | 
			
		||||
					callback: function (value, _index, _ticks) {
 | 
			
		||||
						const label = this.getLabelForValue(value);
 | 
			
		||||
 | 
			
		||||
						// Handle empty or invalid labels
 | 
			
		||||
						if (!label || typeof label !== "string") {
 | 
			
		||||
							return "Unknown";
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						// Format hourly labels (e.g., "2025-10-07T14" -> "2 PM")
 | 
			
		||||
						if (label.includes("T")) {
 | 
			
		||||
							try {
 | 
			
		||||
								const hour = label.split("T")[1];
 | 
			
		||||
								const hourNum = parseInt(hour, 10);
 | 
			
		||||
 | 
			
		||||
								// Validate hour number
 | 
			
		||||
								if (isNaN(hourNum) || hourNum < 0 || hourNum > 23) {
 | 
			
		||||
									return hour; // Return original hour if invalid
 | 
			
		||||
								}
 | 
			
		||||
 | 
			
		||||
								return hourNum === 0
 | 
			
		||||
									? "12 AM"
 | 
			
		||||
									: hourNum < 12
 | 
			
		||||
										? `${hourNum} AM`
 | 
			
		||||
										: hourNum === 12
 | 
			
		||||
											? "12 PM"
 | 
			
		||||
											: `${hourNum - 12} PM`;
 | 
			
		||||
							} catch (error) {
 | 
			
		||||
								return label; // Return original label if parsing fails
 | 
			
		||||
							}
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						// Format daily labels (e.g., "2025-10-07" -> "Oct 7")
 | 
			
		||||
						try {
 | 
			
		||||
							const date = new Date(label);
 | 
			
		||||
							// Check if date is valid
 | 
			
		||||
							if (isNaN(date.getTime())) {
 | 
			
		||||
								return label; // Return original label if date is invalid
 | 
			
		||||
							}
 | 
			
		||||
							return date.toLocaleDateString("en-US", {
 | 
			
		||||
								month: "short",
 | 
			
		||||
								day: "numeric",
 | 
			
		||||
							});
 | 
			
		||||
						} catch (error) {
 | 
			
		||||
							return label; // Return original label if parsing fails
 | 
			
		||||
						}
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
				grid: {
 | 
			
		||||
					color: isDark ? "#374151" : "#E5E7EB",
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			y: {
 | 
			
		||||
				display: true,
 | 
			
		||||
				title: {
 | 
			
		||||
					display: true,
 | 
			
		||||
					text: "Number of Packages",
 | 
			
		||||
					color: isDark ? "#ffffff" : "#374151",
 | 
			
		||||
				},
 | 
			
		||||
				ticks: {
 | 
			
		||||
					color: isDark ? "#ffffff" : "#374151",
 | 
			
		||||
					font: {
 | 
			
		||||
						size: 11,
 | 
			
		||||
					},
 | 
			
		||||
					beginAtZero: true,
 | 
			
		||||
				},
 | 
			
		||||
				grid: {
 | 
			
		||||
					color: isDark ? "#374151" : "#E5E7EB",
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		interaction: {
 | 
			
		||||
			mode: "nearest",
 | 
			
		||||
			axis: "x",
 | 
			
		||||
			intersect: false,
 | 
			
		||||
		},
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const barChartOptions = {
 | 
			
		||||
		responsive: true,
 | 
			
		||||
		indexAxis: "y", // Make the chart horizontal
 | 
			
		||||
@@ -1100,6 +1316,7 @@ const Dashboard = () => {
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		onClick: handleOSChartClick,
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const osChartData = {
 | 
			
		||||
@@ -1245,7 +1462,12 @@ const Dashboard = () => {
 | 
			
		||||
								className={getGroupClassName(group.type)}
 | 
			
		||||
							>
 | 
			
		||||
								{group.cards.map((card, cardIndex) => (
 | 
			
		||||
									<div key={`card-${card.cardId}-${groupIndex}-${cardIndex}`}>
 | 
			
		||||
									<div
 | 
			
		||||
										key={`card-${card.cardId}-${groupIndex}-${cardIndex}`}
 | 
			
		||||
										className={
 | 
			
		||||
											card.cardId === "packageTrends" ? "lg:col-span-2" : ""
 | 
			
		||||
										}
 | 
			
		||||
									>
 | 
			
		||||
										{renderCard(card.cardId)}
 | 
			
		||||
									</div>
 | 
			
		||||
								))}
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -657,6 +657,18 @@ const Hosts = () => {
 | 
			
		||||
		hideStale,
 | 
			
		||||
	]);
 | 
			
		||||
 | 
			
		||||
	// Get unique OS types from hosts for dynamic dropdown
 | 
			
		||||
	const uniqueOsTypes = useMemo(() => {
 | 
			
		||||
		if (!hosts) return [];
 | 
			
		||||
		const osTypes = new Set();
 | 
			
		||||
		hosts.forEach((host) => {
 | 
			
		||||
			if (host.os_type) {
 | 
			
		||||
				osTypes.add(host.os_type);
 | 
			
		||||
			}
 | 
			
		||||
		});
 | 
			
		||||
		return Array.from(osTypes).sort();
 | 
			
		||||
	}, [hosts]);
 | 
			
		||||
 | 
			
		||||
	// Group hosts by selected field
 | 
			
		||||
	const groupedHosts = useMemo(() => {
 | 
			
		||||
		if (groupBy === "none") {
 | 
			
		||||
@@ -870,9 +882,11 @@ const Hosts = () => {
 | 
			
		||||
				return (
 | 
			
		||||
					<button
 | 
			
		||||
						type="button"
 | 
			
		||||
						onClick={() => navigate(`/packages?host=${host.id}`)}
 | 
			
		||||
						onClick={() =>
 | 
			
		||||
							navigate(`/packages?host=${host.id}&filter=outdated`)
 | 
			
		||||
						}
 | 
			
		||||
						className="text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 font-medium hover:underline"
 | 
			
		||||
						title="View packages for this host"
 | 
			
		||||
						title="View outdated packages for this host"
 | 
			
		||||
					>
 | 
			
		||||
						{host.updatesCount || 0}
 | 
			
		||||
					</button>
 | 
			
		||||
@@ -1266,9 +1280,11 @@ const Hosts = () => {
 | 
			
		||||
											className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
 | 
			
		||||
										>
 | 
			
		||||
											<option value="all">All OS</option>
 | 
			
		||||
											<option value="linux">Linux</option>
 | 
			
		||||
											<option value="windows">Windows</option>
 | 
			
		||||
											<option value="macos">macOS</option>
 | 
			
		||||
											{uniqueOsTypes.map((osType) => (
 | 
			
		||||
												<option key={osType} value={osType.toLowerCase()}>
 | 
			
		||||
													{osType}
 | 
			
		||||
												</option>
 | 
			
		||||
											))}
 | 
			
		||||
										</select>
 | 
			
		||||
									</div>
 | 
			
		||||
									<div className="flex items-end">
 | 
			
		||||
@@ -1554,6 +1570,7 @@ const BulkAssignModal = ({
 | 
			
		||||
	isLoading,
 | 
			
		||||
}) => {
 | 
			
		||||
	const [selectedGroupId, setSelectedGroupId] = useState("");
 | 
			
		||||
	const bulkHostGroupId = useId();
 | 
			
		||||
 | 
			
		||||
	// Fetch host groups for selection
 | 
			
		||||
	const { data: hostGroups } = useQuery({
 | 
			
		||||
@@ -1572,28 +1589,31 @@ const BulkAssignModal = ({
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
 | 
			
		||||
			<div className="bg-white rounded-lg p-6 w-full max-w-md">
 | 
			
		||||
			<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
 | 
			
		||||
				<div className="flex justify-between items-center mb-4">
 | 
			
		||||
					<h3 className="text-lg font-semibold text-secondary-900">
 | 
			
		||||
					<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
 | 
			
		||||
						Assign to Host Group
 | 
			
		||||
					</h3>
 | 
			
		||||
					<button
 | 
			
		||||
						type="button"
 | 
			
		||||
						onClick={onClose}
 | 
			
		||||
						className="text-secondary-400 hover:text-secondary-600"
 | 
			
		||||
						className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-300 dark:hover:text-secondary-100"
 | 
			
		||||
					>
 | 
			
		||||
						<X className="h-5 w-5" />
 | 
			
		||||
					</button>
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				<div className="mb-4">
 | 
			
		||||
					<p className="text-sm text-secondary-600 mb-2">
 | 
			
		||||
					<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-2">
 | 
			
		||||
						Assigning {selectedHosts.length} host
 | 
			
		||||
						{selectedHosts.length !== 1 ? "s" : ""}:
 | 
			
		||||
					</p>
 | 
			
		||||
					<div className="max-h-32 overflow-y-auto bg-secondary-50 rounded-md p-3">
 | 
			
		||||
					<div className="max-h-32 overflow-y-auto bg-secondary-50 dark:bg-secondary-700 rounded-md p-3">
 | 
			
		||||
						{selectedHostNames.map((friendlyName) => (
 | 
			
		||||
							<div key={friendlyName} className="text-sm text-secondary-700">
 | 
			
		||||
							<div
 | 
			
		||||
								key={friendlyName}
 | 
			
		||||
								className="text-sm text-secondary-700 dark:text-secondary-300"
 | 
			
		||||
							>
 | 
			
		||||
								• {friendlyName}
 | 
			
		||||
							</div>
 | 
			
		||||
						))}
 | 
			
		||||
@@ -1604,7 +1624,7 @@ const BulkAssignModal = ({
 | 
			
		||||
					<div>
 | 
			
		||||
						<label
 | 
			
		||||
							htmlFor={bulkHostGroupId}
 | 
			
		||||
							className="block text-sm font-medium text-secondary-700 mb-1"
 | 
			
		||||
							className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1"
 | 
			
		||||
						>
 | 
			
		||||
							Host Group
 | 
			
		||||
						</label>
 | 
			
		||||
@@ -1612,7 +1632,7 @@ const BulkAssignModal = ({
 | 
			
		||||
							id={bulkHostGroupId}
 | 
			
		||||
							value={selectedGroupId}
 | 
			
		||||
							onChange={(e) => setSelectedGroupId(e.target.value)}
 | 
			
		||||
							className="w-full px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
 | 
			
		||||
							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 focus:outline-none focus:ring-2 focus:ring-primary-500"
 | 
			
		||||
						>
 | 
			
		||||
							<option value="">No group (ungrouped)</option>
 | 
			
		||||
							{hostGroups?.map((group) => (
 | 
			
		||||
@@ -1621,7 +1641,7 @@ const BulkAssignModal = ({
 | 
			
		||||
								</option>
 | 
			
		||||
							))}
 | 
			
		||||
						</select>
 | 
			
		||||
						<p className="mt-1 text-sm text-secondary-500">
 | 
			
		||||
						<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
 | 
			
		||||
							Select a group to assign these hosts to, or leave ungrouped.
 | 
			
		||||
						</p>
 | 
			
		||||
					</div>
 | 
			
		||||
 
 | 
			
		||||
@@ -22,6 +22,7 @@ const Login = () => {
 | 
			
		||||
	const emailId = useId();
 | 
			
		||||
	const passwordId = useId();
 | 
			
		||||
	const tokenId = useId();
 | 
			
		||||
	const rememberMeId = useId();
 | 
			
		||||
	const { login, setAuthState } = useAuth();
 | 
			
		||||
	const [isSignupMode, setIsSignupMode] = useState(false);
 | 
			
		||||
	const [formData, setFormData] = useState({
 | 
			
		||||
@@ -33,6 +34,7 @@ const Login = () => {
 | 
			
		||||
	});
 | 
			
		||||
	const [tfaData, setTfaData] = useState({
 | 
			
		||||
		token: "",
 | 
			
		||||
		remember_me: false,
 | 
			
		||||
	});
 | 
			
		||||
	const [showPassword, setShowPassword] = useState(false);
 | 
			
		||||
	const [isLoading, setIsLoading] = useState(false);
 | 
			
		||||
@@ -127,7 +129,11 @@ const Login = () => {
 | 
			
		||||
		setError("");
 | 
			
		||||
 | 
			
		||||
		try {
 | 
			
		||||
			const response = await authAPI.verifyTfa(tfaUsername, tfaData.token);
 | 
			
		||||
			const response = await authAPI.verifyTfa(
 | 
			
		||||
				tfaUsername,
 | 
			
		||||
				tfaData.token,
 | 
			
		||||
				tfaData.remember_me,
 | 
			
		||||
			);
 | 
			
		||||
 | 
			
		||||
			if (response.data?.token) {
 | 
			
		||||
				// Update AuthContext with the new authentication state
 | 
			
		||||
@@ -158,9 +164,11 @@ const Login = () => {
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const handleTfaInputChange = (e) => {
 | 
			
		||||
		const { name, value, type, checked } = e.target;
 | 
			
		||||
		setTfaData({
 | 
			
		||||
			...tfaData,
 | 
			
		||||
			[e.target.name]: e.target.value.replace(/\D/g, "").slice(0, 6),
 | 
			
		||||
			[name]:
 | 
			
		||||
				type === "checkbox" ? checked : value.replace(/\D/g, "").slice(0, 6),
 | 
			
		||||
		});
 | 
			
		||||
		// Clear error when user starts typing
 | 
			
		||||
		if (error) {
 | 
			
		||||
@@ -170,7 +178,7 @@ const Login = () => {
 | 
			
		||||
 | 
			
		||||
	const handleBackToLogin = () => {
 | 
			
		||||
		setRequiresTfa(false);
 | 
			
		||||
		setTfaData({ token: "" });
 | 
			
		||||
		setTfaData({ token: "", remember_me: false });
 | 
			
		||||
		setError("");
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
@@ -436,6 +444,23 @@ const Login = () => {
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
 | 
			
		||||
						<div className="flex items-center">
 | 
			
		||||
							<input
 | 
			
		||||
								id={rememberMeId}
 | 
			
		||||
								name="remember_me"
 | 
			
		||||
								type="checkbox"
 | 
			
		||||
								checked={tfaData.remember_me}
 | 
			
		||||
								onChange={handleTfaInputChange}
 | 
			
		||||
								className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
 | 
			
		||||
							/>
 | 
			
		||||
							<label
 | 
			
		||||
								htmlFor={rememberMeId}
 | 
			
		||||
								className="ml-2 block text-sm text-secondary-700"
 | 
			
		||||
							>
 | 
			
		||||
								Remember me on this computer (skip TFA for 30 days)
 | 
			
		||||
							</label>
 | 
			
		||||
						</div>
 | 
			
		||||
 | 
			
		||||
						{error && (
 | 
			
		||||
							<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
 | 
			
		||||
								<div className="flex">
 | 
			
		||||
 
 | 
			
		||||
@@ -105,6 +105,10 @@ const Packages = () => {
 | 
			
		||||
			// For security updates, filter to show only security updates
 | 
			
		||||
			setUpdateStatusFilter("security-updates");
 | 
			
		||||
			setCategoryFilter("all");
 | 
			
		||||
		} else if (filter === "regular") {
 | 
			
		||||
			// For regular (non-security) updates
 | 
			
		||||
			setUpdateStatusFilter("regular-updates");
 | 
			
		||||
			setCategoryFilter("all");
 | 
			
		||||
		}
 | 
			
		||||
	}, [searchParams]);
 | 
			
		||||
 | 
			
		||||
@@ -115,8 +119,20 @@ const Packages = () => {
 | 
			
		||||
		refetch,
 | 
			
		||||
		isFetching,
 | 
			
		||||
	} = useQuery({
 | 
			
		||||
		queryKey: ["packages"],
 | 
			
		||||
		queryFn: () => packagesAPI.getAll({ limit: 1000 }).then((res) => res.data),
 | 
			
		||||
		queryKey: ["packages", hostFilter, updateStatusFilter],
 | 
			
		||||
		queryFn: () => {
 | 
			
		||||
			const params = { limit: 10000 }; // High limit to effectively get all packages
 | 
			
		||||
			if (hostFilter && hostFilter !== "all") {
 | 
			
		||||
				params.host = hostFilter;
 | 
			
		||||
			}
 | 
			
		||||
			// Pass update status filter to backend to pre-filter packages
 | 
			
		||||
			if (updateStatusFilter === "needs-updates") {
 | 
			
		||||
				params.needsUpdate = "true";
 | 
			
		||||
			} else if (updateStatusFilter === "security-updates") {
 | 
			
		||||
				params.isSecurityUpdate = "true";
 | 
			
		||||
			}
 | 
			
		||||
			return packagesAPI.getAll(params).then((res) => res.data);
 | 
			
		||||
		},
 | 
			
		||||
		staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
 | 
			
		||||
		refetchOnWindowFocus: false, // Don't refetch when window regains focus
 | 
			
		||||
	});
 | 
			
		||||
@@ -160,15 +176,13 @@ const Packages = () => {
 | 
			
		||||
 | 
			
		||||
			const matchesUpdateStatus =
 | 
			
		||||
				updateStatusFilter === "all-packages" ||
 | 
			
		||||
				updateStatusFilter === "needs-updates" ||
 | 
			
		||||
				(updateStatusFilter === "security-updates" && pkg.isSecurityUpdate) ||
 | 
			
		||||
				(updateStatusFilter === "regular-updates" && !pkg.isSecurityUpdate);
 | 
			
		||||
 | 
			
		||||
			// 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;
 | 
			
		||||
				(updateStatusFilter === "needs-updates" &&
 | 
			
		||||
					(pkg.stats?.updatesNeeded || 0) > 0) ||
 | 
			
		||||
				(updateStatusFilter === "security-updates" &&
 | 
			
		||||
					(pkg.stats?.securityUpdates || 0) > 0) ||
 | 
			
		||||
				(updateStatusFilter === "regular-updates" &&
 | 
			
		||||
					(pkg.stats?.updatesNeeded || 0) > 0 &&
 | 
			
		||||
					(pkg.stats?.securityUpdates || 0) === 0);
 | 
			
		||||
 | 
			
		||||
			const packageHosts = pkg.packageHosts || [];
 | 
			
		||||
			const matchesHost =
 | 
			
		||||
@@ -176,11 +190,7 @@ const Packages = () => {
 | 
			
		||||
				packageHosts.some((host) => host.hostId === hostFilter);
 | 
			
		||||
 | 
			
		||||
			return (
 | 
			
		||||
				matchesSearch &&
 | 
			
		||||
				matchesCategory &&
 | 
			
		||||
				matchesUpdateStatus &&
 | 
			
		||||
				matchesUpdateNeeded &&
 | 
			
		||||
				matchesHost
 | 
			
		||||
				matchesSearch && matchesCategory && matchesUpdateStatus && matchesHost
 | 
			
		||||
			);
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
@@ -435,8 +445,16 @@ const Packages = () => {
 | 
			
		||||
	});
 | 
			
		||||
	const uniquePackageHostsCount = uniquePackageHosts.size;
 | 
			
		||||
 | 
			
		||||
	// Calculate total packages available
 | 
			
		||||
	const totalPackagesCount = packages?.length || 0;
 | 
			
		||||
	// Calculate total packages installed
 | 
			
		||||
	// When filtering by host, count each package once (since it can only be installed once per host)
 | 
			
		||||
	// When not filtering, sum up all installations across all hosts
 | 
			
		||||
	const totalPackagesCount =
 | 
			
		||||
		hostFilter && hostFilter !== "all"
 | 
			
		||||
			? packages?.length || 0
 | 
			
		||||
			: packages?.reduce(
 | 
			
		||||
					(sum, pkg) => sum + (pkg.stats?.totalInstalls || 0),
 | 
			
		||||
					0,
 | 
			
		||||
				) || 0;
 | 
			
		||||
 | 
			
		||||
	// Calculate outdated packages
 | 
			
		||||
	const outdatedPackagesCount =
 | 
			
		||||
@@ -517,7 +535,7 @@ const Packages = () => {
 | 
			
		||||
						<Package className="h-5 w-5 text-primary-600 mr-2" />
 | 
			
		||||
						<div>
 | 
			
		||||
							<p className="text-sm text-secondary-500 dark:text-white">
 | 
			
		||||
								Total Packages
 | 
			
		||||
								Total Installed
 | 
			
		||||
							</p>
 | 
			
		||||
							<p className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
			
		||||
								{totalPackagesCount}
 | 
			
		||||
 
 | 
			
		||||
@@ -2,12 +2,16 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 | 
			
		||||
import {
 | 
			
		||||
	AlertCircle,
 | 
			
		||||
	CheckCircle,
 | 
			
		||||
	Clock,
 | 
			
		||||
	Copy,
 | 
			
		||||
	Download,
 | 
			
		||||
	Eye,
 | 
			
		||||
	EyeOff,
 | 
			
		||||
	Key,
 | 
			
		||||
	LogOut,
 | 
			
		||||
	Mail,
 | 
			
		||||
	MapPin,
 | 
			
		||||
	Monitor,
 | 
			
		||||
	Moon,
 | 
			
		||||
	RefreshCw,
 | 
			
		||||
	Save,
 | 
			
		||||
@@ -153,6 +157,7 @@ const Profile = () => {
 | 
			
		||||
		{ id: "profile", name: "Profile Information", icon: User },
 | 
			
		||||
		{ id: "password", name: "Change Password", icon: Key },
 | 
			
		||||
		{ id: "tfa", name: "Multi-Factor Authentication", icon: Smartphone },
 | 
			
		||||
		{ id: "sessions", name: "Active Sessions", icon: Monitor },
 | 
			
		||||
	];
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
@@ -533,6 +538,9 @@ const Profile = () => {
 | 
			
		||||
 | 
			
		||||
					{/* Multi-Factor Authentication Tab */}
 | 
			
		||||
					{activeTab === "tfa" && <TfaTab />}
 | 
			
		||||
 | 
			
		||||
					{/* Sessions Tab */}
 | 
			
		||||
					{activeTab === "sessions" && <SessionsTab />}
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
@@ -1072,4 +1080,256 @@ const TfaTab = () => {
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Sessions Tab Component
 | 
			
		||||
const SessionsTab = () => {
 | 
			
		||||
	const _queryClient = useQueryClient();
 | 
			
		||||
	const [_isLoading, _setIsLoading] = useState(false);
 | 
			
		||||
	const [message, setMessage] = useState({ type: "", text: "" });
 | 
			
		||||
 | 
			
		||||
	// Fetch user sessions
 | 
			
		||||
	const {
 | 
			
		||||
		data: sessionsData,
 | 
			
		||||
		isLoading: sessionsLoading,
 | 
			
		||||
		refetch,
 | 
			
		||||
	} = useQuery({
 | 
			
		||||
		queryKey: ["user-sessions"],
 | 
			
		||||
		queryFn: async () => {
 | 
			
		||||
			const response = await fetch("/api/v1/auth/sessions", {
 | 
			
		||||
				headers: {
 | 
			
		||||
					Authorization: `Bearer ${localStorage.getItem("token")}`,
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
			if (!response.ok) throw new Error("Failed to fetch sessions");
 | 
			
		||||
			return response.json();
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Revoke individual session mutation
 | 
			
		||||
	const revokeSessionMutation = useMutation({
 | 
			
		||||
		mutationFn: async (sessionId) => {
 | 
			
		||||
			const response = await fetch(`/api/v1/auth/sessions/${sessionId}`, {
 | 
			
		||||
				method: "DELETE",
 | 
			
		||||
				headers: {
 | 
			
		||||
					Authorization: `Bearer ${localStorage.getItem("token")}`,
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
			if (!response.ok) throw new Error("Failed to revoke session");
 | 
			
		||||
			return response.json();
 | 
			
		||||
		},
 | 
			
		||||
		onSuccess: () => {
 | 
			
		||||
			setMessage({ type: "success", text: "Session revoked successfully" });
 | 
			
		||||
			refetch();
 | 
			
		||||
		},
 | 
			
		||||
		onError: (error) => {
 | 
			
		||||
			setMessage({ type: "error", text: error.message });
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Revoke all sessions mutation
 | 
			
		||||
	const revokeAllSessionsMutation = useMutation({
 | 
			
		||||
		mutationFn: async () => {
 | 
			
		||||
			const response = await fetch("/api/v1/auth/sessions", {
 | 
			
		||||
				method: "DELETE",
 | 
			
		||||
				headers: {
 | 
			
		||||
					Authorization: `Bearer ${localStorage.getItem("token")}`,
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
			if (!response.ok) throw new Error("Failed to revoke sessions");
 | 
			
		||||
			return response.json();
 | 
			
		||||
		},
 | 
			
		||||
		onSuccess: () => {
 | 
			
		||||
			setMessage({
 | 
			
		||||
				type: "success",
 | 
			
		||||
				text: "All other sessions revoked successfully",
 | 
			
		||||
			});
 | 
			
		||||
			refetch();
 | 
			
		||||
		},
 | 
			
		||||
		onError: (error) => {
 | 
			
		||||
			setMessage({ type: "error", text: error.message });
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	const formatDate = (dateString) => {
 | 
			
		||||
		return new Date(dateString).toLocaleString();
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const formatRelativeTime = (dateString) => {
 | 
			
		||||
		const now = new Date();
 | 
			
		||||
		const date = new Date(dateString);
 | 
			
		||||
		const diff = now - date;
 | 
			
		||||
		const minutes = Math.floor(diff / 60000);
 | 
			
		||||
		const hours = Math.floor(diff / 3600000);
 | 
			
		||||
		const days = Math.floor(diff / 86400000);
 | 
			
		||||
 | 
			
		||||
		if (days > 0) return `${days} day${days > 1 ? "s" : ""} ago`;
 | 
			
		||||
		if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""} ago`;
 | 
			
		||||
		if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
 | 
			
		||||
		return "Just now";
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const handleRevokeSession = (sessionId) => {
 | 
			
		||||
		if (window.confirm("Are you sure you want to revoke this session?")) {
 | 
			
		||||
			revokeSessionMutation.mutate(sessionId);
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const handleRevokeAllSessions = () => {
 | 
			
		||||
		if (
 | 
			
		||||
			window.confirm(
 | 
			
		||||
				"Are you sure you want to revoke all other sessions? This will log you out of all other devices.",
 | 
			
		||||
			)
 | 
			
		||||
		) {
 | 
			
		||||
			revokeAllSessionsMutation.mutate();
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="space-y-6">
 | 
			
		||||
			{/* Header */}
 | 
			
		||||
			<div>
 | 
			
		||||
				<h3 className="text-lg font-medium text-secondary-900 dark:text-secondary-100">
 | 
			
		||||
					Active Sessions
 | 
			
		||||
				</h3>
 | 
			
		||||
				<p className="text-sm text-secondary-600 dark:text-secondary-300">
 | 
			
		||||
					Manage your active sessions and devices. You can see where you're
 | 
			
		||||
					logged in and revoke access for any device.
 | 
			
		||||
				</p>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			{/* Message */}
 | 
			
		||||
			{message.text && (
 | 
			
		||||
				<div
 | 
			
		||||
					className={`rounded-md p-4 ${
 | 
			
		||||
						message.type === "success"
 | 
			
		||||
							? "bg-success-50 border border-success-200 text-success-700"
 | 
			
		||||
							: "bg-danger-50 border border-danger-200 text-danger-700"
 | 
			
		||||
					}`}
 | 
			
		||||
				>
 | 
			
		||||
					<div className="flex">
 | 
			
		||||
						{message.type === "success" ? (
 | 
			
		||||
							<CheckCircle className="h-5 w-5" />
 | 
			
		||||
						) : (
 | 
			
		||||
							<AlertCircle className="h-5 w-5" />
 | 
			
		||||
						)}
 | 
			
		||||
						<div className="ml-3">
 | 
			
		||||
							<p className="text-sm">{message.text}</p>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			)}
 | 
			
		||||
 | 
			
		||||
			{/* Sessions List */}
 | 
			
		||||
			{sessionsLoading ? (
 | 
			
		||||
				<div className="flex items-center justify-center py-8">
 | 
			
		||||
					<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
 | 
			
		||||
				</div>
 | 
			
		||||
			) : sessionsData?.sessions?.length > 0 ? (
 | 
			
		||||
				<div className="space-y-4">
 | 
			
		||||
					{/* Revoke All Button */}
 | 
			
		||||
					{sessionsData.sessions.filter((s) => !s.is_current_session).length >
 | 
			
		||||
						0 && (
 | 
			
		||||
						<div className="flex justify-end">
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={handleRevokeAllSessions}
 | 
			
		||||
								disabled={revokeAllSessionsMutation.isPending}
 | 
			
		||||
								className="inline-flex items-center px-4 py-2 border border-danger-300 text-sm font-medium rounded-md text-danger-700 bg-white hover:bg-danger-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-danger-500 disabled:opacity-50"
 | 
			
		||||
							>
 | 
			
		||||
								<LogOut className="h-4 w-4 mr-2" />
 | 
			
		||||
								{revokeAllSessionsMutation.isPending
 | 
			
		||||
									? "Revoking..."
 | 
			
		||||
									: "Revoke All Other Sessions"}
 | 
			
		||||
							</button>
 | 
			
		||||
						</div>
 | 
			
		||||
					)}
 | 
			
		||||
 | 
			
		||||
					{/* Sessions */}
 | 
			
		||||
					{sessionsData.sessions.map((session) => (
 | 
			
		||||
						<div
 | 
			
		||||
							key={session.id}
 | 
			
		||||
							className={`border rounded-lg p-4 ${
 | 
			
		||||
								session.is_current_session
 | 
			
		||||
									? "border-primary-200 bg-primary-50 dark:border-primary-800 dark:bg-primary-900/20"
 | 
			
		||||
									: "border-secondary-200 bg-white dark:border-secondary-700 dark:bg-secondary-800"
 | 
			
		||||
							}`}
 | 
			
		||||
						>
 | 
			
		||||
							<div className="flex items-start justify-between">
 | 
			
		||||
								<div className="flex-1">
 | 
			
		||||
									<div className="flex items-center space-x-3">
 | 
			
		||||
										<Monitor className="h-5 w-5 text-secondary-500" />
 | 
			
		||||
										<div>
 | 
			
		||||
											<div className="flex items-center space-x-2">
 | 
			
		||||
												<h4 className="text-sm font-medium text-secondary-900 dark:text-secondary-100">
 | 
			
		||||
													{session.device_info?.browser} on{" "}
 | 
			
		||||
													{session.device_info?.os}
 | 
			
		||||
												</h4>
 | 
			
		||||
												{session.is_current_session && (
 | 
			
		||||
													<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
 | 
			
		||||
														Current Session
 | 
			
		||||
													</span>
 | 
			
		||||
												)}
 | 
			
		||||
												{session.tfa_remember_me && (
 | 
			
		||||
													<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-success-100 text-success-800 dark:bg-success-900 dark:text-success-200">
 | 
			
		||||
														Remembered
 | 
			
		||||
													</span>
 | 
			
		||||
												)}
 | 
			
		||||
											</div>
 | 
			
		||||
											<p className="text-sm text-secondary-600 dark:text-secondary-400">
 | 
			
		||||
												{session.device_info?.device} • {session.ip_address}
 | 
			
		||||
											</p>
 | 
			
		||||
										</div>
 | 
			
		||||
									</div>
 | 
			
		||||
 | 
			
		||||
									<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-secondary-600 dark:text-secondary-400">
 | 
			
		||||
										<div className="flex items-center space-x-2">
 | 
			
		||||
											<MapPin className="h-4 w-4" />
 | 
			
		||||
											<span>
 | 
			
		||||
												{session.location_info?.city},{" "}
 | 
			
		||||
												{session.location_info?.country}
 | 
			
		||||
											</span>
 | 
			
		||||
										</div>
 | 
			
		||||
										<div className="flex items-center space-x-2">
 | 
			
		||||
											<Clock className="h-4 w-4" />
 | 
			
		||||
											<span>
 | 
			
		||||
												Last active: {formatRelativeTime(session.last_activity)}
 | 
			
		||||
											</span>
 | 
			
		||||
										</div>
 | 
			
		||||
										<div className="flex items-center space-x-2">
 | 
			
		||||
											<span>Created: {formatDate(session.created_at)}</span>
 | 
			
		||||
										</div>
 | 
			
		||||
										<div className="flex items-center space-x-2">
 | 
			
		||||
											<span>Login count: {session.login_count}</span>
 | 
			
		||||
										</div>
 | 
			
		||||
									</div>
 | 
			
		||||
								</div>
 | 
			
		||||
 | 
			
		||||
								{!session.is_current_session && (
 | 
			
		||||
									<button
 | 
			
		||||
										type="button"
 | 
			
		||||
										onClick={() => handleRevokeSession(session.id)}
 | 
			
		||||
										disabled={revokeSessionMutation.isPending}
 | 
			
		||||
										className="ml-4 inline-flex items-center px-3 py-2 border border-danger-300 text-sm font-medium rounded-md text-danger-700 bg-white hover:bg-danger-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-danger-500 disabled:opacity-50"
 | 
			
		||||
									>
 | 
			
		||||
										<LogOut className="h-4 w-4" />
 | 
			
		||||
									</button>
 | 
			
		||||
								)}
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					))}
 | 
			
		||||
				</div>
 | 
			
		||||
			) : (
 | 
			
		||||
				<div className="text-center py-8">
 | 
			
		||||
					<Monitor className="mx-auto h-12 w-12 text-secondary-400" />
 | 
			
		||||
					<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-secondary-100">
 | 
			
		||||
						No active sessions
 | 
			
		||||
					</h3>
 | 
			
		||||
					<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
 | 
			
		||||
						You don't have any active sessions at the moment.
 | 
			
		||||
					</p>
 | 
			
		||||
				</div>
 | 
			
		||||
			)}
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default Profile;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										699
									
								
								frontend/src/pages/Queue.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										699
									
								
								frontend/src/pages/Queue.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,699 @@
 | 
			
		||||
import {
 | 
			
		||||
	Activity,
 | 
			
		||||
	AlertCircle,
 | 
			
		||||
	CheckCircle,
 | 
			
		||||
	Clock,
 | 
			
		||||
	Download,
 | 
			
		||||
	Eye,
 | 
			
		||||
	Filter,
 | 
			
		||||
	Package,
 | 
			
		||||
	Pause,
 | 
			
		||||
	Play,
 | 
			
		||||
	RefreshCw,
 | 
			
		||||
	Search,
 | 
			
		||||
	Server,
 | 
			
		||||
	XCircle,
 | 
			
		||||
} from "lucide-react";
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
 | 
			
		||||
const Queue = () => {
 | 
			
		||||
	const [activeTab, setActiveTab] = useState("server");
 | 
			
		||||
	const [filterStatus, setFilterStatus] = useState("all");
 | 
			
		||||
	const [searchQuery, setSearchQuery] = useState("");
 | 
			
		||||
 | 
			
		||||
	// Mock data for demonstration
 | 
			
		||||
	const serverQueueData = [
 | 
			
		||||
		{
 | 
			
		||||
			id: 1,
 | 
			
		||||
			type: "Server Update Check",
 | 
			
		||||
			description: "Check for server updates from GitHub",
 | 
			
		||||
			status: "running",
 | 
			
		||||
			priority: "high",
 | 
			
		||||
			createdAt: "2024-01-15 10:30:00",
 | 
			
		||||
			estimatedCompletion: "2024-01-15 10:35:00",
 | 
			
		||||
			progress: 75,
 | 
			
		||||
			retryCount: 0,
 | 
			
		||||
			maxRetries: 3,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			id: 2,
 | 
			
		||||
			type: "Session Cleanup",
 | 
			
		||||
			description: "Clear expired login sessions",
 | 
			
		||||
			status: "pending",
 | 
			
		||||
			priority: "medium",
 | 
			
		||||
			createdAt: "2024-01-15 10:25:00",
 | 
			
		||||
			estimatedCompletion: "2024-01-15 10:40:00",
 | 
			
		||||
			progress: 0,
 | 
			
		||||
			retryCount: 0,
 | 
			
		||||
			maxRetries: 2,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			id: 3,
 | 
			
		||||
			type: "Database Optimization",
 | 
			
		||||
			description: "Optimize database indexes and cleanup old records",
 | 
			
		||||
			status: "completed",
 | 
			
		||||
			priority: "low",
 | 
			
		||||
			createdAt: "2024-01-15 09:00:00",
 | 
			
		||||
			completedAt: "2024-01-15 09:45:00",
 | 
			
		||||
			progress: 100,
 | 
			
		||||
			retryCount: 0,
 | 
			
		||||
			maxRetries: 1,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			id: 4,
 | 
			
		||||
			type: "Backup Creation",
 | 
			
		||||
			description: "Create system backup",
 | 
			
		||||
			status: "failed",
 | 
			
		||||
			priority: "high",
 | 
			
		||||
			createdAt: "2024-01-15 08:00:00",
 | 
			
		||||
			errorMessage: "Insufficient disk space",
 | 
			
		||||
			progress: 45,
 | 
			
		||||
			retryCount: 2,
 | 
			
		||||
			maxRetries: 3,
 | 
			
		||||
		},
 | 
			
		||||
	];
 | 
			
		||||
 | 
			
		||||
	const agentQueueData = [
 | 
			
		||||
		{
 | 
			
		||||
			id: 1,
 | 
			
		||||
			hostname: "web-server-01",
 | 
			
		||||
			ip: "192.168.1.100",
 | 
			
		||||
			type: "Agent Update Collection",
 | 
			
		||||
			description: "Agent v1.2.7 → v1.2.8",
 | 
			
		||||
			status: "pending",
 | 
			
		||||
			priority: "medium",
 | 
			
		||||
			lastCommunication: "2024-01-15 10:00:00",
 | 
			
		||||
			nextExpectedCommunication: "2024-01-15 11:00:00",
 | 
			
		||||
			currentVersion: "1.2.7",
 | 
			
		||||
			targetVersion: "1.2.8",
 | 
			
		||||
			retryCount: 0,
 | 
			
		||||
			maxRetries: 5,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			id: 2,
 | 
			
		||||
			hostname: "db-server-02",
 | 
			
		||||
			ip: "192.168.1.101",
 | 
			
		||||
			type: "Data Collection",
 | 
			
		||||
			description: "Collect package and system information",
 | 
			
		||||
			status: "running",
 | 
			
		||||
			priority: "high",
 | 
			
		||||
			lastCommunication: "2024-01-15 10:15:00",
 | 
			
		||||
			nextExpectedCommunication: "2024-01-15 11:15:00",
 | 
			
		||||
			currentVersion: "1.2.8",
 | 
			
		||||
			targetVersion: "1.2.8",
 | 
			
		||||
			retryCount: 0,
 | 
			
		||||
			maxRetries: 3,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			id: 3,
 | 
			
		||||
			hostname: "app-server-03",
 | 
			
		||||
			ip: "192.168.1.102",
 | 
			
		||||
			type: "Agent Update Collection",
 | 
			
		||||
			description: "Agent v1.2.6 → v1.2.8",
 | 
			
		||||
			status: "completed",
 | 
			
		||||
			priority: "low",
 | 
			
		||||
			lastCommunication: "2024-01-15 09:30:00",
 | 
			
		||||
			completedAt: "2024-01-15 09:45:00",
 | 
			
		||||
			currentVersion: "1.2.8",
 | 
			
		||||
			targetVersion: "1.2.8",
 | 
			
		||||
			retryCount: 0,
 | 
			
		||||
			maxRetries: 5,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			id: 4,
 | 
			
		||||
			hostname: "test-server-04",
 | 
			
		||||
			ip: "192.168.1.103",
 | 
			
		||||
			type: "Data Collection",
 | 
			
		||||
			description: "Collect package and system information",
 | 
			
		||||
			status: "failed",
 | 
			
		||||
			priority: "medium",
 | 
			
		||||
			lastCommunication: "2024-01-15 08:00:00",
 | 
			
		||||
			errorMessage: "Connection timeout",
 | 
			
		||||
			retryCount: 3,
 | 
			
		||||
			maxRetries: 3,
 | 
			
		||||
		},
 | 
			
		||||
	];
 | 
			
		||||
 | 
			
		||||
	const patchQueueData = [
 | 
			
		||||
		{
 | 
			
		||||
			id: 1,
 | 
			
		||||
			hostname: "web-server-01",
 | 
			
		||||
			ip: "192.168.1.100",
 | 
			
		||||
			packages: ["nginx", "openssl", "curl"],
 | 
			
		||||
			type: "Security Updates",
 | 
			
		||||
			description: "Apply critical security patches",
 | 
			
		||||
			status: "pending",
 | 
			
		||||
			priority: "high",
 | 
			
		||||
			scheduledFor: "2024-01-15 19:00:00",
 | 
			
		||||
			lastCommunication: "2024-01-15 18:00:00",
 | 
			
		||||
			nextExpectedCommunication: "2024-01-15 19:00:00",
 | 
			
		||||
			retryCount: 0,
 | 
			
		||||
			maxRetries: 3,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			id: 2,
 | 
			
		||||
			hostname: "db-server-02",
 | 
			
		||||
			ip: "192.168.1.101",
 | 
			
		||||
			packages: ["postgresql", "python3"],
 | 
			
		||||
			type: "Feature Updates",
 | 
			
		||||
			description: "Update database and Python packages",
 | 
			
		||||
			status: "running",
 | 
			
		||||
			priority: "medium",
 | 
			
		||||
			scheduledFor: "2024-01-15 20:00:00",
 | 
			
		||||
			lastCommunication: "2024-01-15 19:15:00",
 | 
			
		||||
			nextExpectedCommunication: "2024-01-15 20:15:00",
 | 
			
		||||
			retryCount: 0,
 | 
			
		||||
			maxRetries: 2,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			id: 3,
 | 
			
		||||
			hostname: "app-server-03",
 | 
			
		||||
			ip: "192.168.1.102",
 | 
			
		||||
			packages: ["nodejs", "npm"],
 | 
			
		||||
			type: "Maintenance Updates",
 | 
			
		||||
			description: "Update Node.js and npm packages",
 | 
			
		||||
			status: "completed",
 | 
			
		||||
			priority: "low",
 | 
			
		||||
			scheduledFor: "2024-01-15 18:30:00",
 | 
			
		||||
			completedAt: "2024-01-15 18:45:00",
 | 
			
		||||
			retryCount: 0,
 | 
			
		||||
			maxRetries: 2,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			id: 4,
 | 
			
		||||
			hostname: "test-server-04",
 | 
			
		||||
			ip: "192.168.1.103",
 | 
			
		||||
			packages: ["docker", "docker-compose"],
 | 
			
		||||
			type: "Security Updates",
 | 
			
		||||
			description: "Update Docker components",
 | 
			
		||||
			status: "failed",
 | 
			
		||||
			priority: "high",
 | 
			
		||||
			scheduledFor: "2024-01-15 17:00:00",
 | 
			
		||||
			errorMessage: "Package conflicts detected",
 | 
			
		||||
			retryCount: 2,
 | 
			
		||||
			maxRetries: 3,
 | 
			
		||||
		},
 | 
			
		||||
	];
 | 
			
		||||
 | 
			
		||||
	const getStatusIcon = (status) => {
 | 
			
		||||
		switch (status) {
 | 
			
		||||
			case "running":
 | 
			
		||||
				return <RefreshCw className="h-4 w-4 text-blue-500 animate-spin" />;
 | 
			
		||||
			case "completed":
 | 
			
		||||
				return <CheckCircle className="h-4 w-4 text-green-500" />;
 | 
			
		||||
			case "failed":
 | 
			
		||||
				return <XCircle className="h-4 w-4 text-red-500" />;
 | 
			
		||||
			case "pending":
 | 
			
		||||
				return <Clock className="h-4 w-4 text-yellow-500" />;
 | 
			
		||||
			case "paused":
 | 
			
		||||
				return <Pause className="h-4 w-4 text-gray-500" />;
 | 
			
		||||
			default:
 | 
			
		||||
				return <AlertCircle className="h-4 w-4 text-gray-500" />;
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const getStatusColor = (status) => {
 | 
			
		||||
		switch (status) {
 | 
			
		||||
			case "running":
 | 
			
		||||
				return "bg-blue-100 text-blue-800";
 | 
			
		||||
			case "completed":
 | 
			
		||||
				return "bg-green-100 text-green-800";
 | 
			
		||||
			case "failed":
 | 
			
		||||
				return "bg-red-100 text-red-800";
 | 
			
		||||
			case "pending":
 | 
			
		||||
				return "bg-yellow-100 text-yellow-800";
 | 
			
		||||
			case "paused":
 | 
			
		||||
				return "bg-gray-100 text-gray-800";
 | 
			
		||||
			default:
 | 
			
		||||
				return "bg-gray-100 text-gray-800";
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const getPriorityColor = (priority) => {
 | 
			
		||||
		switch (priority) {
 | 
			
		||||
			case "high":
 | 
			
		||||
				return "bg-red-100 text-red-800";
 | 
			
		||||
			case "medium":
 | 
			
		||||
				return "bg-yellow-100 text-yellow-800";
 | 
			
		||||
			case "low":
 | 
			
		||||
				return "bg-green-100 text-green-800";
 | 
			
		||||
			default:
 | 
			
		||||
				return "bg-gray-100 text-gray-800";
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const filteredData = (data) => {
 | 
			
		||||
		let filtered = data;
 | 
			
		||||
 | 
			
		||||
		if (filterStatus !== "all") {
 | 
			
		||||
			filtered = filtered.filter((item) => item.status === filterStatus);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (searchQuery) {
 | 
			
		||||
			filtered = filtered.filter(
 | 
			
		||||
				(item) =>
 | 
			
		||||
					item.hostname?.toLowerCase().includes(searchQuery.toLowerCase()) ||
 | 
			
		||||
					item.type?.toLowerCase().includes(searchQuery.toLowerCase()) ||
 | 
			
		||||
					item.description?.toLowerCase().includes(searchQuery.toLowerCase()),
 | 
			
		||||
			);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return filtered;
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const tabs = [
 | 
			
		||||
		{
 | 
			
		||||
			id: "server",
 | 
			
		||||
			name: "Server Queue",
 | 
			
		||||
			icon: Server,
 | 
			
		||||
			data: serverQueueData,
 | 
			
		||||
			count: serverQueueData.length,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			id: "agent",
 | 
			
		||||
			name: "Agent Queue",
 | 
			
		||||
			icon: Download,
 | 
			
		||||
			data: agentQueueData,
 | 
			
		||||
			count: agentQueueData.length,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			id: "patch",
 | 
			
		||||
			name: "Patch Management",
 | 
			
		||||
			icon: Package,
 | 
			
		||||
			data: patchQueueData,
 | 
			
		||||
			count: patchQueueData.length,
 | 
			
		||||
		},
 | 
			
		||||
	];
 | 
			
		||||
 | 
			
		||||
	const renderServerQueueItem = (item) => (
 | 
			
		||||
		<div
 | 
			
		||||
			key={item.id}
 | 
			
		||||
			className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 hover:shadow-md transition-shadow"
 | 
			
		||||
		>
 | 
			
		||||
			<div className="flex items-start justify-between">
 | 
			
		||||
				<div className="flex-1">
 | 
			
		||||
					<div className="flex items-center gap-3 mb-2">
 | 
			
		||||
						{getStatusIcon(item.status)}
 | 
			
		||||
						<h3 className="font-medium text-gray-900 dark:text-white">
 | 
			
		||||
							{item.type}
 | 
			
		||||
						</h3>
 | 
			
		||||
						<span
 | 
			
		||||
							className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(item.status)}`}
 | 
			
		||||
						>
 | 
			
		||||
							{item.status}
 | 
			
		||||
						</span>
 | 
			
		||||
						<span
 | 
			
		||||
							className={`px-2 py-1 text-xs font-medium rounded-full ${getPriorityColor(item.priority)}`}
 | 
			
		||||
						>
 | 
			
		||||
							{item.priority}
 | 
			
		||||
						</span>
 | 
			
		||||
					</div>
 | 
			
		||||
					<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
 | 
			
		||||
						{item.description}
 | 
			
		||||
					</p>
 | 
			
		||||
 | 
			
		||||
					{item.status === "running" && (
 | 
			
		||||
						<div className="mb-3">
 | 
			
		||||
							<div className="flex justify-between text-xs text-gray-500 mb-1">
 | 
			
		||||
								<span>Progress</span>
 | 
			
		||||
								<span>{item.progress}%</span>
 | 
			
		||||
							</div>
 | 
			
		||||
							<div className="w-full bg-gray-200 rounded-full h-2">
 | 
			
		||||
								<div
 | 
			
		||||
									className="bg-blue-600 h-2 rounded-full transition-all duration-300"
 | 
			
		||||
									style={{ width: `${item.progress}%` }}
 | 
			
		||||
								></div>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					)}
 | 
			
		||||
 | 
			
		||||
					<div className="grid grid-cols-2 gap-4 text-xs text-gray-500">
 | 
			
		||||
						<div>
 | 
			
		||||
							<span className="font-medium">Created:</span> {item.createdAt}
 | 
			
		||||
						</div>
 | 
			
		||||
						{item.status === "running" && (
 | 
			
		||||
							<div>
 | 
			
		||||
								<span className="font-medium">ETA:</span>{" "}
 | 
			
		||||
								{item.estimatedCompletion}
 | 
			
		||||
							</div>
 | 
			
		||||
						)}
 | 
			
		||||
						{item.status === "completed" && (
 | 
			
		||||
							<div>
 | 
			
		||||
								<span className="font-medium">Completed:</span>{" "}
 | 
			
		||||
								{item.completedAt}
 | 
			
		||||
							</div>
 | 
			
		||||
						)}
 | 
			
		||||
						{item.status === "failed" && (
 | 
			
		||||
							<div className="col-span-2">
 | 
			
		||||
								<span className="font-medium">Error:</span> {item.errorMessage}
 | 
			
		||||
							</div>
 | 
			
		||||
						)}
 | 
			
		||||
					</div>
 | 
			
		||||
 | 
			
		||||
					{item.retryCount > 0 && (
 | 
			
		||||
						<div className="mt-2 text-xs text-orange-600">
 | 
			
		||||
							Retries: {item.retryCount}/{item.maxRetries}
 | 
			
		||||
						</div>
 | 
			
		||||
					)}
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				<div className="flex gap-2 ml-4">
 | 
			
		||||
					{item.status === "running" && (
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
							className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
 | 
			
		||||
						>
 | 
			
		||||
							<Pause className="h-4 w-4" />
 | 
			
		||||
						</button>
 | 
			
		||||
					)}
 | 
			
		||||
					{item.status === "paused" && (
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
							className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
 | 
			
		||||
						>
 | 
			
		||||
							<Play className="h-4 w-4" />
 | 
			
		||||
						</button>
 | 
			
		||||
					)}
 | 
			
		||||
					{item.status === "failed" && (
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
							className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
 | 
			
		||||
						>
 | 
			
		||||
							<RefreshCw className="h-4 w-4" />
 | 
			
		||||
						</button>
 | 
			
		||||
					)}
 | 
			
		||||
					<button
 | 
			
		||||
						type="button"
 | 
			
		||||
						className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
 | 
			
		||||
					>
 | 
			
		||||
						<Eye className="h-4 w-4" />
 | 
			
		||||
					</button>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
 | 
			
		||||
	const renderAgentQueueItem = (item) => (
 | 
			
		||||
		<div
 | 
			
		||||
			key={item.id}
 | 
			
		||||
			className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 hover:shadow-md transition-shadow"
 | 
			
		||||
		>
 | 
			
		||||
			<div className="flex items-start justify-between">
 | 
			
		||||
				<div className="flex-1">
 | 
			
		||||
					<div className="flex items-center gap-3 mb-2">
 | 
			
		||||
						{getStatusIcon(item.status)}
 | 
			
		||||
						<h3 className="font-medium text-gray-900 dark:text-white">
 | 
			
		||||
							{item.hostname}
 | 
			
		||||
						</h3>
 | 
			
		||||
						<span
 | 
			
		||||
							className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(item.status)}`}
 | 
			
		||||
						>
 | 
			
		||||
							{item.status}
 | 
			
		||||
						</span>
 | 
			
		||||
						<span
 | 
			
		||||
							className={`px-2 py-1 text-xs font-medium rounded-full ${getPriorityColor(item.priority)}`}
 | 
			
		||||
						>
 | 
			
		||||
							{item.priority}
 | 
			
		||||
						</span>
 | 
			
		||||
					</div>
 | 
			
		||||
					<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
 | 
			
		||||
						{item.type}
 | 
			
		||||
					</p>
 | 
			
		||||
					<p className="text-sm text-gray-500 mb-3">{item.description}</p>
 | 
			
		||||
 | 
			
		||||
					{item.type === "Agent Update Collection" && (
 | 
			
		||||
						<div className="mb-3 p-2 bg-gray-50 dark:bg-gray-700 rounded">
 | 
			
		||||
							<div className="text-xs text-gray-600 dark:text-gray-400">
 | 
			
		||||
								<span className="font-medium">Version:</span>{" "}
 | 
			
		||||
								{item.currentVersion} → {item.targetVersion}
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					)}
 | 
			
		||||
 | 
			
		||||
					<div className="grid grid-cols-2 gap-4 text-xs text-gray-500">
 | 
			
		||||
						<div>
 | 
			
		||||
							<span className="font-medium">IP:</span> {item.ip}
 | 
			
		||||
						</div>
 | 
			
		||||
						<div>
 | 
			
		||||
							<span className="font-medium">Last Comm:</span>{" "}
 | 
			
		||||
							{item.lastCommunication}
 | 
			
		||||
						</div>
 | 
			
		||||
						<div>
 | 
			
		||||
							<span className="font-medium">Next Expected:</span>{" "}
 | 
			
		||||
							{item.nextExpectedCommunication}
 | 
			
		||||
						</div>
 | 
			
		||||
						{item.status === "completed" && (
 | 
			
		||||
							<div>
 | 
			
		||||
								<span className="font-medium">Completed:</span>{" "}
 | 
			
		||||
								{item.completedAt}
 | 
			
		||||
							</div>
 | 
			
		||||
						)}
 | 
			
		||||
						{item.status === "failed" && (
 | 
			
		||||
							<div className="col-span-2">
 | 
			
		||||
								<span className="font-medium">Error:</span> {item.errorMessage}
 | 
			
		||||
							</div>
 | 
			
		||||
						)}
 | 
			
		||||
					</div>
 | 
			
		||||
 | 
			
		||||
					{item.retryCount > 0 && (
 | 
			
		||||
						<div className="mt-2 text-xs text-orange-600">
 | 
			
		||||
							Retries: {item.retryCount}/{item.maxRetries}
 | 
			
		||||
						</div>
 | 
			
		||||
					)}
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				<div className="flex gap-2 ml-4">
 | 
			
		||||
					{item.status === "failed" && (
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
							className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
 | 
			
		||||
						>
 | 
			
		||||
							<RefreshCw className="h-4 w-4" />
 | 
			
		||||
						</button>
 | 
			
		||||
					)}
 | 
			
		||||
					<button
 | 
			
		||||
						type="button"
 | 
			
		||||
						className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
 | 
			
		||||
					>
 | 
			
		||||
						<Eye className="h-4 w-4" />
 | 
			
		||||
					</button>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
 | 
			
		||||
	const renderPatchQueueItem = (item) => (
 | 
			
		||||
		<div
 | 
			
		||||
			key={item.id}
 | 
			
		||||
			className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 hover:shadow-md transition-shadow"
 | 
			
		||||
		>
 | 
			
		||||
			<div className="flex items-start justify-between">
 | 
			
		||||
				<div className="flex-1">
 | 
			
		||||
					<div className="flex items-center gap-3 mb-2">
 | 
			
		||||
						{getStatusIcon(item.status)}
 | 
			
		||||
						<h3 className="font-medium text-gray-900 dark:text-white">
 | 
			
		||||
							{item.hostname}
 | 
			
		||||
						</h3>
 | 
			
		||||
						<span
 | 
			
		||||
							className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(item.status)}`}
 | 
			
		||||
						>
 | 
			
		||||
							{item.status}
 | 
			
		||||
						</span>
 | 
			
		||||
						<span
 | 
			
		||||
							className={`px-2 py-1 text-xs font-medium rounded-full ${getPriorityColor(item.priority)}`}
 | 
			
		||||
						>
 | 
			
		||||
							{item.priority}
 | 
			
		||||
						</span>
 | 
			
		||||
					</div>
 | 
			
		||||
					<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
 | 
			
		||||
						{item.type}
 | 
			
		||||
					</p>
 | 
			
		||||
					<p className="text-sm text-gray-500 mb-3">{item.description}</p>
 | 
			
		||||
 | 
			
		||||
					<div className="mb-3">
 | 
			
		||||
						<div className="text-xs text-gray-600 dark:text-gray-400 mb-1">
 | 
			
		||||
							<span className="font-medium">Packages:</span>
 | 
			
		||||
						</div>
 | 
			
		||||
						<div className="flex flex-wrap gap-1">
 | 
			
		||||
							{item.packages.map((pkg) => (
 | 
			
		||||
								<span
 | 
			
		||||
									key={pkg}
 | 
			
		||||
									className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded"
 | 
			
		||||
								>
 | 
			
		||||
									{pkg}
 | 
			
		||||
								</span>
 | 
			
		||||
							))}
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
 | 
			
		||||
					<div className="grid grid-cols-2 gap-4 text-xs text-gray-500">
 | 
			
		||||
						<div>
 | 
			
		||||
							<span className="font-medium">IP:</span> {item.ip}
 | 
			
		||||
						</div>
 | 
			
		||||
						<div>
 | 
			
		||||
							<span className="font-medium">Scheduled:</span>{" "}
 | 
			
		||||
							{item.scheduledFor}
 | 
			
		||||
						</div>
 | 
			
		||||
						<div>
 | 
			
		||||
							<span className="font-medium">Last Comm:</span>{" "}
 | 
			
		||||
							{item.lastCommunication}
 | 
			
		||||
						</div>
 | 
			
		||||
						<div>
 | 
			
		||||
							<span className="font-medium">Next Expected:</span>{" "}
 | 
			
		||||
							{item.nextExpectedCommunication}
 | 
			
		||||
						</div>
 | 
			
		||||
						{item.status === "completed" && (
 | 
			
		||||
							<div>
 | 
			
		||||
								<span className="font-medium">Completed:</span>{" "}
 | 
			
		||||
								{item.completedAt}
 | 
			
		||||
							</div>
 | 
			
		||||
						)}
 | 
			
		||||
						{item.status === "failed" && (
 | 
			
		||||
							<div className="col-span-2">
 | 
			
		||||
								<span className="font-medium">Error:</span> {item.errorMessage}
 | 
			
		||||
							</div>
 | 
			
		||||
						)}
 | 
			
		||||
					</div>
 | 
			
		||||
 | 
			
		||||
					{item.retryCount > 0 && (
 | 
			
		||||
						<div className="mt-2 text-xs text-orange-600">
 | 
			
		||||
							Retries: {item.retryCount}/{item.maxRetries}
 | 
			
		||||
						</div>
 | 
			
		||||
					)}
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				<div className="flex gap-2 ml-4">
 | 
			
		||||
					{item.status === "failed" && (
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
							className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
 | 
			
		||||
						>
 | 
			
		||||
							<RefreshCw className="h-4 w-4" />
 | 
			
		||||
						</button>
 | 
			
		||||
					)}
 | 
			
		||||
					<button
 | 
			
		||||
						type="button"
 | 
			
		||||
						className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
 | 
			
		||||
					>
 | 
			
		||||
						<Eye className="h-4 w-4" />
 | 
			
		||||
					</button>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
 | 
			
		||||
	const currentTab = tabs.find((tab) => tab.id === activeTab);
 | 
			
		||||
	const filteredItems = filteredData(currentTab?.data || []);
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
 | 
			
		||||
			<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
 | 
			
		||||
				{/* Header */}
 | 
			
		||||
				<div className="mb-8">
 | 
			
		||||
					<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
 | 
			
		||||
						Queue Management
 | 
			
		||||
					</h1>
 | 
			
		||||
					<p className="text-gray-600 dark:text-gray-400">
 | 
			
		||||
						Monitor and manage server operations, agent communications, and
 | 
			
		||||
						patch deployments
 | 
			
		||||
					</p>
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				{/* Tabs */}
 | 
			
		||||
				<div className="mb-6">
 | 
			
		||||
					<div className="border-b border-gray-200 dark:border-gray-700">
 | 
			
		||||
						<nav className="-mb-px flex space-x-8">
 | 
			
		||||
							{tabs.map((tab) => (
 | 
			
		||||
								<button
 | 
			
		||||
									type="button"
 | 
			
		||||
									key={tab.id}
 | 
			
		||||
									onClick={() => setActiveTab(tab.id)}
 | 
			
		||||
									className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
 | 
			
		||||
										activeTab === tab.id
 | 
			
		||||
											? "border-blue-500 text-blue-600 dark:text-blue-400"
 | 
			
		||||
											: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
 | 
			
		||||
									}`}
 | 
			
		||||
								>
 | 
			
		||||
									<tab.icon className="h-4 w-4" />
 | 
			
		||||
									{tab.name}
 | 
			
		||||
									<span className="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 px-2 py-0.5 rounded-full text-xs">
 | 
			
		||||
										{tab.count}
 | 
			
		||||
									</span>
 | 
			
		||||
								</button>
 | 
			
		||||
							))}
 | 
			
		||||
						</nav>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				{/* Filters and Search */}
 | 
			
		||||
				<div className="mb-6 flex flex-col sm:flex-row gap-4">
 | 
			
		||||
					<div className="flex-1">
 | 
			
		||||
						<div className="relative">
 | 
			
		||||
							<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
 | 
			
		||||
							<input
 | 
			
		||||
								type="text"
 | 
			
		||||
								placeholder="Search queues..."
 | 
			
		||||
								value={searchQuery}
 | 
			
		||||
								onChange={(e) => setSearchQuery(e.target.value)}
 | 
			
		||||
								className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
 | 
			
		||||
							/>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div className="flex gap-2">
 | 
			
		||||
						<select
 | 
			
		||||
							value={filterStatus}
 | 
			
		||||
							onChange={(e) => setFilterStatus(e.target.value)}
 | 
			
		||||
							className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
 | 
			
		||||
						>
 | 
			
		||||
							<option value="all">All Status</option>
 | 
			
		||||
							<option value="pending">Pending</option>
 | 
			
		||||
							<option value="running">Running</option>
 | 
			
		||||
							<option value="completed">Completed</option>
 | 
			
		||||
							<option value="failed">Failed</option>
 | 
			
		||||
							<option value="paused">Paused</option>
 | 
			
		||||
						</select>
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
							className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-2"
 | 
			
		||||
						>
 | 
			
		||||
							<Filter className="h-4 w-4" />
 | 
			
		||||
							More Filters
 | 
			
		||||
						</button>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				{/* Queue Items */}
 | 
			
		||||
				<div className="space-y-4">
 | 
			
		||||
					{filteredItems.length === 0 ? (
 | 
			
		||||
						<div className="text-center py-12">
 | 
			
		||||
							<Activity className="mx-auto h-12 w-12 text-gray-400" />
 | 
			
		||||
							<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">
 | 
			
		||||
								No queue items found
 | 
			
		||||
							</h3>
 | 
			
		||||
							<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
 | 
			
		||||
								{searchQuery
 | 
			
		||||
									? "Try adjusting your search criteria"
 | 
			
		||||
									: "No items match the current filters"}
 | 
			
		||||
							</p>
 | 
			
		||||
						</div>
 | 
			
		||||
					) : (
 | 
			
		||||
						filteredItems.map((item) => {
 | 
			
		||||
							switch (activeTab) {
 | 
			
		||||
								case "server":
 | 
			
		||||
									return renderServerQueueItem(item);
 | 
			
		||||
								case "agent":
 | 
			
		||||
									return renderAgentQueueItem(item);
 | 
			
		||||
								case "patch":
 | 
			
		||||
									return renderPatchQueueItem(item);
 | 
			
		||||
								default:
 | 
			
		||||
									return null;
 | 
			
		||||
							}
 | 
			
		||||
						})
 | 
			
		||||
					)}
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default Queue;
 | 
			
		||||
@@ -18,21 +18,31 @@ import {
 | 
			
		||||
	Unlock,
 | 
			
		||||
	X,
 | 
			
		||||
} from "lucide-react";
 | 
			
		||||
import { useMemo, useState } from "react";
 | 
			
		||||
import { useNavigate } from "react-router-dom";
 | 
			
		||||
import { repositoryAPI } from "../utils/api";
 | 
			
		||||
import { useEffect, useMemo, useState } from "react";
 | 
			
		||||
import { useNavigate, useSearchParams } from "react-router-dom";
 | 
			
		||||
import { dashboardAPI, repositoryAPI } from "../utils/api";
 | 
			
		||||
 | 
			
		||||
const Repositories = () => {
 | 
			
		||||
	const queryClient = useQueryClient();
 | 
			
		||||
	const navigate = useNavigate();
 | 
			
		||||
	const [searchParams] = useSearchParams();
 | 
			
		||||
	const [searchTerm, setSearchTerm] = useState("");
 | 
			
		||||
	const [filterType, setFilterType] = useState("all"); // all, secure, insecure
 | 
			
		||||
	const [filterStatus, setFilterStatus] = useState("all"); // all, active, inactive
 | 
			
		||||
	const [hostFilter, setHostFilter] = useState("");
 | 
			
		||||
	const [sortField, setSortField] = useState("name");
 | 
			
		||||
	const [sortDirection, setSortDirection] = useState("asc");
 | 
			
		||||
	const [showColumnSettings, setShowColumnSettings] = useState(false);
 | 
			
		||||
	const [deleteModalData, setDeleteModalData] = useState(null);
 | 
			
		||||
 | 
			
		||||
	// Handle host filter from URL parameter
 | 
			
		||||
	useEffect(() => {
 | 
			
		||||
		const hostParam = searchParams.get("host");
 | 
			
		||||
		if (hostParam) {
 | 
			
		||||
			setHostFilter(hostParam);
 | 
			
		||||
		}
 | 
			
		||||
	}, [searchParams]);
 | 
			
		||||
 | 
			
		||||
	// Column configuration
 | 
			
		||||
	const [columnConfig, setColumnConfig] = useState(() => {
 | 
			
		||||
		const defaultConfig = [
 | 
			
		||||
@@ -82,6 +92,17 @@ const Repositories = () => {
 | 
			
		||||
		queryFn: () => repositoryAPI.getStats().then((res) => res.data),
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Fetch host information when filtering by host
 | 
			
		||||
	const { data: hosts } = useQuery({
 | 
			
		||||
		queryKey: ["hosts"],
 | 
			
		||||
		queryFn: () => dashboardAPI.getHosts().then((res) => res.data),
 | 
			
		||||
		staleTime: 5 * 60 * 1000,
 | 
			
		||||
		enabled: !!hostFilter,
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Get the filtered host information
 | 
			
		||||
	const filteredHost = hosts?.find((host) => host.id === hostFilter);
 | 
			
		||||
 | 
			
		||||
	// Delete repository mutation
 | 
			
		||||
	const deleteRepositoryMutation = useMutation({
 | 
			
		||||
		mutationFn: (repositoryId) => repositoryAPI.delete(repositoryId),
 | 
			
		||||
@@ -202,7 +223,11 @@ const Repositories = () => {
 | 
			
		||||
				(filterStatus === "active" && repo.is_active === true) ||
 | 
			
		||||
				(filterStatus === "inactive" && repo.is_active === false);
 | 
			
		||||
 | 
			
		||||
			return matchesSearch && matchesType && matchesStatus;
 | 
			
		||||
			// Filter by host if hostFilter is set
 | 
			
		||||
			const matchesHost =
 | 
			
		||||
				!hostFilter || repo.hosts?.some((host) => host.id === hostFilter);
 | 
			
		||||
 | 
			
		||||
			return matchesSearch && matchesType && matchesStatus && matchesHost;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		// Sort repositories
 | 
			
		||||
@@ -237,6 +262,7 @@ const Repositories = () => {
 | 
			
		||||
		filterStatus,
 | 
			
		||||
		sortField,
 | 
			
		||||
		sortDirection,
 | 
			
		||||
		hostFilter,
 | 
			
		||||
	]);
 | 
			
		||||
 | 
			
		||||
	if (isLoading) {
 | 
			
		||||
@@ -421,6 +447,31 @@ const Repositories = () => {
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
 | 
			
		||||
							{/* Host Filter Indicator */}
 | 
			
		||||
							{hostFilter && filteredHost && (
 | 
			
		||||
								<div className="flex items-center gap-2 px-3 py-2 bg-primary-50 dark:bg-primary-900 border border-primary-200 dark:border-primary-700 rounded-md">
 | 
			
		||||
									<Server className="h-4 w-4 text-primary-600 dark:text-primary-400" />
 | 
			
		||||
									<span className="text-sm text-primary-700 dark:text-primary-300">
 | 
			
		||||
										Filtered by: {filteredHost.friendly_name}
 | 
			
		||||
									</span>
 | 
			
		||||
									<button
 | 
			
		||||
										type="button"
 | 
			
		||||
										onClick={() => {
 | 
			
		||||
											setHostFilter("");
 | 
			
		||||
											// Update URL to remove host parameter
 | 
			
		||||
											const newSearchParams = new URLSearchParams(searchParams);
 | 
			
		||||
											newSearchParams.delete("host");
 | 
			
		||||
											navigate(`/repositories?${newSearchParams.toString()}`, {
 | 
			
		||||
												replace: true,
 | 
			
		||||
											});
 | 
			
		||||
										}}
 | 
			
		||||
										className="text-primary-500 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-200"
 | 
			
		||||
									>
 | 
			
		||||
										<X className="h-4 w-4" />
 | 
			
		||||
									</button>
 | 
			
		||||
								</div>
 | 
			
		||||
							)}
 | 
			
		||||
 | 
			
		||||
							{/* Security Filter */}
 | 
			
		||||
							<div className="sm:w-48">
 | 
			
		||||
								<select
 | 
			
		||||
 
 | 
			
		||||
@@ -51,7 +51,16 @@ export const dashboardAPI = {
 | 
			
		||||
	getStats: () => api.get("/dashboard/stats"),
 | 
			
		||||
	getHosts: () => api.get("/dashboard/hosts"),
 | 
			
		||||
	getPackages: () => api.get("/dashboard/packages"),
 | 
			
		||||
	getHostDetail: (hostId) => api.get(`/dashboard/hosts/${hostId}`),
 | 
			
		||||
	getHostDetail: (hostId, params = {}) => {
 | 
			
		||||
		const queryString = new URLSearchParams(params).toString();
 | 
			
		||||
		const url = `/dashboard/hosts/${hostId}${queryString ? `?${queryString}` : ""}`;
 | 
			
		||||
		return api.get(url);
 | 
			
		||||
	},
 | 
			
		||||
	getPackageTrends: (params = {}) => {
 | 
			
		||||
		const queryString = new URLSearchParams(params).toString();
 | 
			
		||||
		const url = `/dashboard/package-trends${queryString ? `?${queryString}` : ""}`;
 | 
			
		||||
		return api.get(url);
 | 
			
		||||
	},
 | 
			
		||||
	getRecentUsers: () => api.get("/dashboard/recent-users"),
 | 
			
		||||
	getRecentCollection: () => api.get("/dashboard/recent-collection"),
 | 
			
		||||
};
 | 
			
		||||
@@ -224,8 +233,8 @@ export const versionAPI = {
 | 
			
		||||
export const authAPI = {
 | 
			
		||||
	login: (username, password) =>
 | 
			
		||||
		api.post("/auth/login", { username, password }),
 | 
			
		||||
	verifyTfa: (username, token) =>
 | 
			
		||||
		api.post("/auth/verify-tfa", { username, token }),
 | 
			
		||||
	verifyTfa: (username, token, remember_me = false) =>
 | 
			
		||||
		api.post("/auth/verify-tfa", { username, token, remember_me }),
 | 
			
		||||
	signup: (username, email, password, firstName, lastName) =>
 | 
			
		||||
		api.post("/auth/signup", {
 | 
			
		||||
			username,
 | 
			
		||||
 
 | 
			
		||||
@@ -24,8 +24,16 @@ export const getOSIcon = (osType) => {
 | 
			
		||||
	// Linux distributions with authentic react-icons
 | 
			
		||||
	if (os.includes("ubuntu")) return SiUbuntu;
 | 
			
		||||
	if (os.includes("debian")) return SiDebian;
 | 
			
		||||
	if (os.includes("centos") || os.includes("rhel") || os.includes("red hat"))
 | 
			
		||||
	if (
 | 
			
		||||
		os.includes("centos") ||
 | 
			
		||||
		os.includes("rhel") ||
 | 
			
		||||
		os.includes("red hat") ||
 | 
			
		||||
		os.includes("almalinux") ||
 | 
			
		||||
		os.includes("rocky")
 | 
			
		||||
	)
 | 
			
		||||
		return SiCentos;
 | 
			
		||||
	if (os === "ol" || os.includes("oraclelinux") || os.includes("oracle linux"))
 | 
			
		||||
		return SiLinux; // Use generic Linux icon for Oracle Linux
 | 
			
		||||
	if (os.includes("fedora")) return SiFedora;
 | 
			
		||||
	if (os.includes("arch")) return SiArchlinux;
 | 
			
		||||
	if (os.includes("alpine")) return SiAlpinelinux;
 | 
			
		||||
@@ -72,6 +80,10 @@ export const getOSDisplayName = (osType) => {
 | 
			
		||||
	if (os.includes("ubuntu")) return "Ubuntu";
 | 
			
		||||
	if (os.includes("debian")) return "Debian";
 | 
			
		||||
	if (os.includes("centos")) return "CentOS";
 | 
			
		||||
	if (os.includes("almalinux")) return "AlmaLinux";
 | 
			
		||||
	if (os.includes("rocky")) return "Rocky Linux";
 | 
			
		||||
	if (os === "ol" || os.includes("oraclelinux") || os.includes("oracle linux"))
 | 
			
		||||
		return "Oracle Linux";
 | 
			
		||||
	if (os.includes("rhel") || os.includes("red hat"))
 | 
			
		||||
		return "Red Hat Enterprise Linux";
 | 
			
		||||
	if (os.includes("fedora")) return "Fedora";
 | 
			
		||||
 
 | 
			
		||||
@@ -43,5 +43,25 @@ export default defineConfig({
 | 
			
		||||
		outDir: "dist",
 | 
			
		||||
		sourcemap: process.env.NODE_ENV !== "production",
 | 
			
		||||
		target: "es2018",
 | 
			
		||||
		rollupOptions: {
 | 
			
		||||
			output: {
 | 
			
		||||
				manualChunks: {
 | 
			
		||||
					// React core
 | 
			
		||||
					"react-vendor": ["react", "react-dom", "react-router-dom"],
 | 
			
		||||
					// Large utility libraries
 | 
			
		||||
					"utils-vendor": ["axios", "@tanstack/react-query", "date-fns"],
 | 
			
		||||
					// Chart libraries
 | 
			
		||||
					"chart-vendor": ["chart.js", "react-chartjs-2"],
 | 
			
		||||
					// Icon libraries
 | 
			
		||||
					"icons-vendor": ["lucide-react", "react-icons"],
 | 
			
		||||
					// DnD libraries
 | 
			
		||||
					"dnd-vendor": [
 | 
			
		||||
						"@dnd-kit/core",
 | 
			
		||||
						"@dnd-kit/sortable",
 | 
			
		||||
						"@dnd-kit/utilities",
 | 
			
		||||
					],
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
	"name": "patchmon",
 | 
			
		||||
	"version": "1.2.7",
 | 
			
		||||
	"version": "1.2.8",
 | 
			
		||||
	"description": "Linux Patch Monitoring System",
 | 
			
		||||
	"license": "AGPL-3.0",
 | 
			
		||||
	"private": true,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										374
									
								
								setup.sh
									
									
									
									
									
								
							
							
						
						
									
										374
									
								
								setup.sh
									
									
									
									
									
								
							@@ -34,7 +34,7 @@ BLUE='\033[0;34m'
 | 
			
		||||
NC='\033[0m' # No Color
 | 
			
		||||
 | 
			
		||||
# Global variables
 | 
			
		||||
SCRIPT_VERSION="self-hosting-install.sh v1.2.7-selfhost-2025-01-20-1"
 | 
			
		||||
SCRIPT_VERSION="self-hosting-install.sh v1.2.8-selfhost-2025-10-10-6"
 | 
			
		||||
DEFAULT_GITHUB_REPO="https://github.com/PatchMon/PatchMon.git"
 | 
			
		||||
FQDN=""
 | 
			
		||||
CUSTOM_FQDN=""
 | 
			
		||||
@@ -60,6 +60,9 @@ SERVICE_USE_LETSENCRYPT="true"  # Will be set based on user input
 | 
			
		||||
SERVER_PROTOCOL_SEL="https"
 | 
			
		||||
SERVER_PORT_SEL=""  # Will be set to BACKEND_PORT in init_instance_vars
 | 
			
		||||
SETUP_NGINX="true"
 | 
			
		||||
UPDATE_MODE="false"
 | 
			
		||||
SELECTED_INSTANCE=""
 | 
			
		||||
SELECTED_SERVICE_NAME=""
 | 
			
		||||
 | 
			
		||||
# Functions
 | 
			
		||||
print_status() {
 | 
			
		||||
@@ -642,31 +645,61 @@ EOF
 | 
			
		||||
 | 
			
		||||
# Setup database for instance
 | 
			
		||||
setup_database() {
 | 
			
		||||
    print_info "Creating database: $DB_NAME"
 | 
			
		||||
    print_info "Setting up database: $DB_NAME"
 | 
			
		||||
    
 | 
			
		||||
    # Check if sudo is available for user switching
 | 
			
		||||
    if command -v sudo >/dev/null 2>&1; then
 | 
			
		||||
        # Drop and recreate database and user for clean state
 | 
			
		||||
        sudo -u postgres psql -c "DROP DATABASE IF EXISTS $DB_NAME;" || true
 | 
			
		||||
        sudo -u postgres psql -c "DROP USER IF EXISTS $DB_USER;" || true
 | 
			
		||||
        # Check if user exists
 | 
			
		||||
        user_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'" || echo "0")
 | 
			
		||||
        
 | 
			
		||||
        # Create database and user
 | 
			
		||||
        sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';"
 | 
			
		||||
        sudo -u postgres psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;"
 | 
			
		||||
        if [ "$user_exists" = "1" ]; then
 | 
			
		||||
            print_info "Database user $DB_USER already exists, skipping creation"
 | 
			
		||||
        else
 | 
			
		||||
            print_info "Creating database user $DB_USER"
 | 
			
		||||
            sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';"
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        # Check if database exists
 | 
			
		||||
        db_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" || echo "0")
 | 
			
		||||
        
 | 
			
		||||
        if [ "$db_exists" = "1" ]; then
 | 
			
		||||
            print_info "Database $DB_NAME already exists, skipping creation"
 | 
			
		||||
        else
 | 
			
		||||
            print_info "Creating database $DB_NAME"
 | 
			
		||||
            sudo -u postgres psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;"
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        # Always grant privileges (in case they were revoked)
 | 
			
		||||
        sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;"
 | 
			
		||||
    else
 | 
			
		||||
        # Alternative method for systems without sudo (run as postgres user directly)
 | 
			
		||||
        print_warning "sudo not available, using alternative method for PostgreSQL setup"
 | 
			
		||||
        
 | 
			
		||||
        # Switch to postgres user using su
 | 
			
		||||
        su - postgres -c "psql -c \"DROP DATABASE IF EXISTS $DB_NAME;\"" || true
 | 
			
		||||
        su - postgres -c "psql -c \"DROP USER IF EXISTS $DB_USER;\"" || true
 | 
			
		||||
        su - postgres -c "psql -c \"CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';\""
 | 
			
		||||
        su - postgres -c "psql -c \"CREATE DATABASE $DB_NAME OWNER $DB_USER;\""
 | 
			
		||||
        # Check if user exists
 | 
			
		||||
        user_exists=$(su - postgres -c "psql -tAc \"SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'\"" || echo "0")
 | 
			
		||||
        
 | 
			
		||||
        if [ "$user_exists" = "1" ]; then
 | 
			
		||||
            print_info "Database user $DB_USER already exists, skipping creation"
 | 
			
		||||
        else
 | 
			
		||||
            print_info "Creating database user $DB_USER"
 | 
			
		||||
            su - postgres -c "psql -c \"CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';\""
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        # Check if database exists
 | 
			
		||||
        db_exists=$(su - postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname='$DB_NAME'\"" || echo "0")
 | 
			
		||||
        
 | 
			
		||||
        if [ "$db_exists" = "1" ]; then
 | 
			
		||||
            print_info "Database $DB_NAME already exists, skipping creation"
 | 
			
		||||
        else
 | 
			
		||||
            print_info "Creating database $DB_NAME"
 | 
			
		||||
            su - postgres -c "psql -c \"CREATE DATABASE $DB_NAME OWNER $DB_USER;\""
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        # Always grant privileges (in case they were revoked)
 | 
			
		||||
        su - postgres -c "psql -c \"GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;\""
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    print_status "Database $DB_NAME created with user $DB_USER"
 | 
			
		||||
    print_status "Database setup complete for $DB_NAME"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Clone application repository
 | 
			
		||||
@@ -834,7 +867,7 @@ EOF
 | 
			
		||||
    cat > frontend/.env << EOF
 | 
			
		||||
VITE_API_URL=$SERVER_PROTOCOL_SEL://$FQDN/api/v1
 | 
			
		||||
VITE_APP_NAME=PatchMon
 | 
			
		||||
VITE_APP_VERSION=1.2.7
 | 
			
		||||
VITE_APP_VERSION=1.2.8
 | 
			
		||||
EOF
 | 
			
		||||
 | 
			
		||||
    print_status "Environment files created"
 | 
			
		||||
@@ -1206,7 +1239,7 @@ create_agent_version() {
 | 
			
		||||
    
 | 
			
		||||
    # Priority 2: Use fallback version if not found
 | 
			
		||||
    if [ "$current_version" = "N/A" ] || [ -z "$current_version" ]; then
 | 
			
		||||
        current_version="1.2.7"
 | 
			
		||||
        current_version="1.2.8"
 | 
			
		||||
        print_warning "Could not determine version, using fallback: $current_version"
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
@@ -1550,11 +1583,287 @@ deploy_instance() {
 | 
			
		||||
    :
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Detect existing PatchMon installations
 | 
			
		||||
detect_installations() {
 | 
			
		||||
    local installations=()
 | 
			
		||||
    
 | 
			
		||||
    # Find all directories in /opt that contain PatchMon installations
 | 
			
		||||
    if [ -d "/opt" ]; then
 | 
			
		||||
        for dir in /opt/*/; do
 | 
			
		||||
            local dirname=$(basename "$dir")
 | 
			
		||||
            # Skip backup directories
 | 
			
		||||
            if [[ "$dirname" =~ \.backup\. ]]; then
 | 
			
		||||
                continue
 | 
			
		||||
            fi
 | 
			
		||||
            # Check if it's a PatchMon installation
 | 
			
		||||
            if [ -f "$dir/backend/package.json" ] && grep -q "patchmon" "$dir/backend/package.json" 2>/dev/null; then
 | 
			
		||||
                installations+=("$dirname")
 | 
			
		||||
            fi
 | 
			
		||||
        done
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    echo "${installations[@]}"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Select installation to update
 | 
			
		||||
select_installation_to_update() {
 | 
			
		||||
    local installations=($(detect_installations))
 | 
			
		||||
    
 | 
			
		||||
    if [ ${#installations[@]} -eq 0 ]; then
 | 
			
		||||
        print_error "No existing PatchMon installations found in /opt"
 | 
			
		||||
        exit 1
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    print_info "Found ${#installations[@]} existing installation(s):"
 | 
			
		||||
    echo ""
 | 
			
		||||
    
 | 
			
		||||
    local i=1
 | 
			
		||||
    declare -A install_map
 | 
			
		||||
    for install in "${installations[@]}"; do
 | 
			
		||||
        # Get current version if possible
 | 
			
		||||
        local version="unknown"
 | 
			
		||||
        if [ -f "/opt/$install/backend/package.json" ]; then
 | 
			
		||||
            version=$(grep '"version"' "/opt/$install/backend/package.json" | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/')
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        # Get service status - try multiple naming conventions
 | 
			
		||||
        # Convention 1: Just the install name (e.g., patchmon.internal)
 | 
			
		||||
        local service_name="$install"
 | 
			
		||||
        # Convention 2: patchmon. prefix (e.g., patchmon.patchmon.internal)
 | 
			
		||||
        local alt_service_name1="patchmon.$install"
 | 
			
		||||
        # Convention 3: patchmon- prefix with underscores (e.g., patchmon-patchmon_internal)
 | 
			
		||||
        local alt_service_name2="patchmon-$(echo "$install" | tr '.' '_')"
 | 
			
		||||
        local status="unknown"
 | 
			
		||||
        
 | 
			
		||||
        # Try convention 1 first (most common)
 | 
			
		||||
        if systemctl is-active --quiet "$service_name" 2>/dev/null; then
 | 
			
		||||
            status="running"
 | 
			
		||||
        elif systemctl is-enabled --quiet "$service_name" 2>/dev/null; then
 | 
			
		||||
            status="stopped"
 | 
			
		||||
        # Try convention 2
 | 
			
		||||
        elif systemctl is-active --quiet "$alt_service_name1" 2>/dev/null; then
 | 
			
		||||
            status="running"
 | 
			
		||||
            service_name="$alt_service_name1"
 | 
			
		||||
        elif systemctl is-enabled --quiet "$alt_service_name1" 2>/dev/null; then
 | 
			
		||||
            status="stopped"
 | 
			
		||||
            service_name="$alt_service_name1"
 | 
			
		||||
        # Try convention 3
 | 
			
		||||
        elif systemctl is-active --quiet "$alt_service_name2" 2>/dev/null; then
 | 
			
		||||
            status="running"
 | 
			
		||||
            service_name="$alt_service_name2"
 | 
			
		||||
        elif systemctl is-enabled --quiet "$alt_service_name2" 2>/dev/null; then
 | 
			
		||||
            status="stopped"
 | 
			
		||||
            service_name="$alt_service_name2"
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        printf "%2d. %-30s (v%-10s - %s)\n" "$i" "$install" "$version" "$status"
 | 
			
		||||
        install_map[$i]="$install"
 | 
			
		||||
        # Store the service name for later use
 | 
			
		||||
        declare -g "service_map_$i=$service_name"
 | 
			
		||||
        i=$((i + 1))
 | 
			
		||||
    done
 | 
			
		||||
    
 | 
			
		||||
    echo ""
 | 
			
		||||
    
 | 
			
		||||
    while true; do
 | 
			
		||||
        read_input "Select installation number to update" SELECTION "1"
 | 
			
		||||
        
 | 
			
		||||
        if [[ "$SELECTION" =~ ^[0-9]+$ ]] && [ -n "${install_map[$SELECTION]}" ]; then
 | 
			
		||||
            SELECTED_INSTANCE="${install_map[$SELECTION]}"
 | 
			
		||||
            # Get the stored service name
 | 
			
		||||
            local varname="service_map_$SELECTION"
 | 
			
		||||
            SELECTED_SERVICE_NAME="${!varname}"
 | 
			
		||||
            print_status "Selected: $SELECTED_INSTANCE"
 | 
			
		||||
            print_info "Service: $SELECTED_SERVICE_NAME"
 | 
			
		||||
            return 0
 | 
			
		||||
        else
 | 
			
		||||
            print_error "Invalid selection. Please enter a number from 1 to ${#installations[@]}"
 | 
			
		||||
        fi
 | 
			
		||||
    done
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Update existing installation
 | 
			
		||||
update_installation() {
 | 
			
		||||
    local instance_dir="/opt/$SELECTED_INSTANCE"
 | 
			
		||||
    local service_name="$SELECTED_SERVICE_NAME"
 | 
			
		||||
    
 | 
			
		||||
    print_info "Updating PatchMon installation: $SELECTED_INSTANCE"
 | 
			
		||||
    print_info "Installation directory: $instance_dir"
 | 
			
		||||
    print_info "Service name: $service_name"
 | 
			
		||||
    
 | 
			
		||||
    # Verify it's a git repository
 | 
			
		||||
    if [ ! -d "$instance_dir/.git" ]; then
 | 
			
		||||
        print_error "Installation directory is not a git repository"
 | 
			
		||||
        print_error "Cannot perform git-based update"
 | 
			
		||||
        exit 1
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Add git safe.directory to avoid ownership issues when running as root
 | 
			
		||||
    print_info "Configuring git safe.directory..."
 | 
			
		||||
    git config --global --add safe.directory "$instance_dir" 2>/dev/null || true
 | 
			
		||||
    
 | 
			
		||||
    # Load existing .env to get database credentials
 | 
			
		||||
    if [ -f "$instance_dir/backend/.env" ]; then
 | 
			
		||||
        source "$instance_dir/backend/.env"
 | 
			
		||||
        print_status "Loaded existing configuration"
 | 
			
		||||
        
 | 
			
		||||
        # Parse DATABASE_URL to extract credentials
 | 
			
		||||
        # Format: postgresql://user:password@host:port/database
 | 
			
		||||
        if [ -n "$DATABASE_URL" ]; then
 | 
			
		||||
            # Extract components using regex
 | 
			
		||||
            DB_USER=$(echo "$DATABASE_URL" | sed -n 's|postgresql://\([^:]*\):.*|\1|p')
 | 
			
		||||
            DB_PASS=$(echo "$DATABASE_URL" | sed -n 's|postgresql://[^:]*:\([^@]*\)@.*|\1|p')
 | 
			
		||||
            DB_HOST=$(echo "$DATABASE_URL" | sed -n 's|.*@\([^:]*\):.*|\1|p')
 | 
			
		||||
            DB_PORT=$(echo "$DATABASE_URL" | sed -n 's|.*:\([0-9]*\)/.*|\1|p')
 | 
			
		||||
            DB_NAME=$(echo "$DATABASE_URL" | sed -n 's|.*/\([^?]*\).*|\1|p')
 | 
			
		||||
            
 | 
			
		||||
            print_info "Database: $DB_NAME (user: $DB_USER)"
 | 
			
		||||
        else
 | 
			
		||||
            print_error "DATABASE_URL not found in .env file"
 | 
			
		||||
            exit 1
 | 
			
		||||
        fi
 | 
			
		||||
    else
 | 
			
		||||
        print_error "Cannot find .env file at $instance_dir/backend/.env"
 | 
			
		||||
        exit 1
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Select branch/version to update to
 | 
			
		||||
    select_branch
 | 
			
		||||
    
 | 
			
		||||
    print_info "Updating to: $DEPLOYMENT_BRANCH"
 | 
			
		||||
    echo ""
 | 
			
		||||
    
 | 
			
		||||
    read_yes_no "Proceed with update? This will pull new code and restart services" CONFIRM_UPDATE "y"
 | 
			
		||||
    
 | 
			
		||||
    if [ "$CONFIRM_UPDATE" != "y" ]; then
 | 
			
		||||
        print_warning "Update cancelled by user"
 | 
			
		||||
        exit 0
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Stop the service
 | 
			
		||||
    print_info "Stopping service: $service_name"
 | 
			
		||||
    systemctl stop "$service_name" || true
 | 
			
		||||
    
 | 
			
		||||
    # Create backup directory
 | 
			
		||||
    local timestamp=$(date +%Y%m%d_%H%M%S)
 | 
			
		||||
    local backup_dir="$instance_dir.backup.$timestamp"
 | 
			
		||||
    local db_backup_file="$backup_dir/database_backup_$timestamp.sql"
 | 
			
		||||
    
 | 
			
		||||
    print_info "Creating backup directory: $backup_dir"
 | 
			
		||||
    mkdir -p "$backup_dir"
 | 
			
		||||
    
 | 
			
		||||
    # Backup database
 | 
			
		||||
    print_info "Backing up database: $DB_NAME"
 | 
			
		||||
    if PGPASSWORD="$DB_PASS" pg_dump -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -F c -f "$db_backup_file" 2>/dev/null; then
 | 
			
		||||
        print_status "Database backup created: $db_backup_file"
 | 
			
		||||
    else
 | 
			
		||||
        print_warning "Database backup failed, but continuing with code backup"
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Backup code
 | 
			
		||||
    print_info "Backing up code files..."
 | 
			
		||||
    cp -r "$instance_dir" "$backup_dir/code"
 | 
			
		||||
    print_status "Code backup created"
 | 
			
		||||
    
 | 
			
		||||
    # Update code
 | 
			
		||||
    print_info "Pulling latest code from branch: $DEPLOYMENT_BRANCH"
 | 
			
		||||
    cd "$instance_dir"
 | 
			
		||||
    
 | 
			
		||||
    # Fetch latest changes
 | 
			
		||||
    git fetch origin
 | 
			
		||||
    
 | 
			
		||||
    # Checkout the selected branch/tag
 | 
			
		||||
    git checkout "$DEPLOYMENT_BRANCH"
 | 
			
		||||
    git pull origin "$DEPLOYMENT_BRANCH" || git pull # For tags, just pull
 | 
			
		||||
    
 | 
			
		||||
    print_status "Code updated successfully"
 | 
			
		||||
    
 | 
			
		||||
    # Update dependencies
 | 
			
		||||
    print_info "Updating backend dependencies..."
 | 
			
		||||
    cd "$instance_dir/backend"
 | 
			
		||||
    npm install --production --ignore-scripts
 | 
			
		||||
    
 | 
			
		||||
    print_info "Updating frontend dependencies..."
 | 
			
		||||
    cd "$instance_dir/frontend"
 | 
			
		||||
    npm install --ignore-scripts
 | 
			
		||||
    
 | 
			
		||||
    # Build frontend
 | 
			
		||||
    print_info "Building frontend..."
 | 
			
		||||
    npm run build
 | 
			
		||||
    
 | 
			
		||||
    # Run database migrations and generate Prisma client
 | 
			
		||||
    print_info "Running database migrations..."
 | 
			
		||||
    cd "$instance_dir/backend"
 | 
			
		||||
    npx prisma generate
 | 
			
		||||
    npx prisma migrate deploy
 | 
			
		||||
    
 | 
			
		||||
    # Start the service
 | 
			
		||||
    print_info "Starting service: $service_name"
 | 
			
		||||
    systemctl start "$service_name"
 | 
			
		||||
    
 | 
			
		||||
    # Wait a moment and check status
 | 
			
		||||
    sleep 3
 | 
			
		||||
    
 | 
			
		||||
    if systemctl is-active --quiet "$service_name"; then
 | 
			
		||||
        print_success "✅ Update completed successfully!"
 | 
			
		||||
        print_status "Service $service_name is running"
 | 
			
		||||
        
 | 
			
		||||
        # Get new version
 | 
			
		||||
        local new_version=$(grep '"version"' "$instance_dir/backend/package.json" | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/')
 | 
			
		||||
        print_info "Updated to version: $new_version"
 | 
			
		||||
        echo ""
 | 
			
		||||
        print_info "Backup Information:"
 | 
			
		||||
        print_info "  Code backup: $backup_dir/code"
 | 
			
		||||
        print_info "  Database backup: $db_backup_file"
 | 
			
		||||
        echo ""
 | 
			
		||||
        print_info "To restore database if needed:"
 | 
			
		||||
        print_info "  PGPASSWORD=\"$DB_PASS\" pg_restore -h \"$DB_HOST\" -U \"$DB_USER\" -d \"$DB_NAME\" -c \"$db_backup_file\""
 | 
			
		||||
        echo ""
 | 
			
		||||
    else
 | 
			
		||||
        print_error "Service failed to start after update"
 | 
			
		||||
        echo ""
 | 
			
		||||
        print_warning "ROLLBACK INSTRUCTIONS:"
 | 
			
		||||
        print_info "1. Restore code:"
 | 
			
		||||
        print_info "   sudo rm -rf $instance_dir"
 | 
			
		||||
        print_info "   sudo mv $backup_dir/code $instance_dir"
 | 
			
		||||
        echo ""
 | 
			
		||||
        print_info "2. Restore database:"
 | 
			
		||||
        print_info "   PGPASSWORD=\"$DB_PASS\" pg_restore -h \"$DB_HOST\" -U \"$DB_USER\" -d \"$DB_NAME\" -c \"$db_backup_file\""
 | 
			
		||||
        echo ""
 | 
			
		||||
        print_info "3. Restart service:"
 | 
			
		||||
        print_info "   sudo systemctl start $service_name"
 | 
			
		||||
        echo ""
 | 
			
		||||
        print_info "Check logs: journalctl -u $service_name -f"
 | 
			
		||||
        exit 1
 | 
			
		||||
    fi
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Main script execution
 | 
			
		||||
main() {
 | 
			
		||||
    # Log script entry
 | 
			
		||||
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] Interactive installation started" >> "$DEBUG_LOG"
 | 
			
		||||
    # Parse command-line arguments
 | 
			
		||||
    if [ "$1" = "--update" ]; then
 | 
			
		||||
        UPDATE_MODE="true"
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Log script entry
 | 
			
		||||
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] Script started - Update mode: $UPDATE_MODE" >> "$DEBUG_LOG"
 | 
			
		||||
    
 | 
			
		||||
    # Handle update mode
 | 
			
		||||
    if [ "$UPDATE_MODE" = "true" ]; then
 | 
			
		||||
        print_banner
 | 
			
		||||
        print_info "🔄 PatchMon Update Mode"
 | 
			
		||||
        echo ""
 | 
			
		||||
        
 | 
			
		||||
        # Select installation to update
 | 
			
		||||
        select_installation_to_update
 | 
			
		||||
        
 | 
			
		||||
        # Perform update
 | 
			
		||||
        update_installation
 | 
			
		||||
        
 | 
			
		||||
        exit 0
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Normal installation mode
 | 
			
		||||
    # Run interactive setup
 | 
			
		||||
    interactive_setup
 | 
			
		||||
    
 | 
			
		||||
@@ -1588,5 +1897,30 @@ main() {
 | 
			
		||||
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] deploy_instance function completed" >> "$DEBUG_LOG"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Run main function (no arguments needed for interactive mode)
 | 
			
		||||
main
 | 
			
		||||
# Show usage/help
 | 
			
		||||
show_usage() {
 | 
			
		||||
    echo "PatchMon Self-Hosting Installation & Update Script"
 | 
			
		||||
    echo "Version: $SCRIPT_VERSION"
 | 
			
		||||
    echo ""
 | 
			
		||||
    echo "Usage:"
 | 
			
		||||
    echo "  $0              # Interactive installation (default)"
 | 
			
		||||
    echo "  $0 --update     # Update existing installation"
 | 
			
		||||
    echo "  $0 --help       # Show this help message"
 | 
			
		||||
    echo ""
 | 
			
		||||
    echo "Examples:"
 | 
			
		||||
    echo "  # New installation:"
 | 
			
		||||
    echo "  sudo bash $0"
 | 
			
		||||
    echo ""
 | 
			
		||||
    echo "  # Update existing installation:"
 | 
			
		||||
    echo "  sudo bash $0 --update"
 | 
			
		||||
    echo ""
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Check for help flag
 | 
			
		||||
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
 | 
			
		||||
    show_usage
 | 
			
		||||
    exit 0
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Run main function
 | 
			
		||||
main "$@"
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user