Merge pull request #134 from PatchMon/dev

Implemented Machine ID check when enrolling a linux host into PatchMon rather than using the friendly name as the unique identifier. Mainly implemented when I worked on the auto-enrollment system for ProxMox LXC Containers
Implemented ProxMox auto-enrollment function where it searches and attaches LXC containers then enrolls them into PatchMon
Add Package deletion ability from tigattack
Made tables and views better and in sync with the rest of the ui by tigattack
Made JWT Token required as a environment variable when starting server.js
Added global search bar
Added PatchMon Logos and ability to change them, with a new branding option in the settings menu
Reworked github fetch for version updates checking to give more details of latest commits
Made changes to the navigation pane
This commit is contained in:
9 Technology Group LTD
2025-10-05 13:05:22 +01:00
committed by GitHub
43 changed files with 6212 additions and 1007 deletions

View File

@@ -1,11 +1,9 @@
name: Build on Merge
on:
push:
branches:
- main
- dev
jobs:
deploy:
runs-on: self-hosted
@@ -15,3 +13,11 @@ jobs:
- name: Run rebuild script
run: /root/patchmon/platform/scripts/app_build.sh ${{ github.ref_name }}
rebuild-pmon:
runs-on: self-hosted
needs: deploy
if: github.ref_name == 'dev'
steps:
- name: Rebuild pmon
run: /root/patchmon/platform/scripts/manage_pmon_auto.sh

View File

@@ -5,6 +5,7 @@
[![GitHub](https://img.shields.io/badge/GitHub-Repository-black?style=for-the-badge&logo=github)](https://github.com/9technologygroup/patchmon.net)
[![Roadmap](https://img.shields.io/badge/Roadmap-View%20Progress-green?style=for-the-badge&logo=github)](https://github.com/users/9technologygroup/projects/1)
[![Documentation](https://img.shields.io/badge/Documentation-docs.patchmon.net-blue?style=for-the-badge&logo=book)](https://docs.patchmon.net/)
---
## Please STAR this repo :D
@@ -13,7 +14,7 @@
PatchMon provides centralized patch management across diverse server environments. Agents communicate outbound-only to the PatchMon server, eliminating inbound ports on monitored hosts while delivering comprehensive visibility and safe automation.
![Dashboard Screenshot](https://raw.githubusercontent.com/9technologygroup/patchmon.net/main/dashboard.jpeg)
![Dashboard Screenshot](https://raw.githubusercontent.com/PatchMon/PatchMon/main/dashboard.jpeg)
## Features
@@ -42,6 +43,7 @@ PatchMon provides centralized patch management across diverse server environment
### API & Integrations
- REST API under `/api/v1` with JWT auth
- **Proxmox LXC Auto-Enrollment** - Automatically discover and enroll LXC containers from Proxmox hosts ([Documentation](PROXMOX_AUTO_ENROLLMENT.md))
### Security
- Rate limiting for general, auth, and agent endpoints
@@ -63,7 +65,7 @@ Managed, zero-maintenance PatchMon hosting. Stay tuned.
#### Docker (preferred)
For getting started with Docker, see the [Docker documentation](https://github.com/9technologygroup/patchmon.net/blob/main/docker/README.md)
For getting started with Docker, see the [Docker documentation](https://github.com/PatchMon/PatchMon/blob/main/docker/README.md)
#### Native Install (advanced/non-docker)
@@ -85,7 +87,7 @@ apt install curl -y
#### Script
```bash
curl -fsSL -o setup.sh https://raw.githubusercontent.com/9technologygroup/patchmon.net/refs/heads/main/setup.sh && chmod +x setup.sh && bash setup.sh
curl -fsSL -o setup.sh https://raw.githubusercontent.com/PatchMon/PatchMon/refs/heads/main/setup.sh && chmod +x setup.sh && bash setup.sh
```
#### Minimum specs for building : #####
@@ -145,7 +147,7 @@ Operational
## Roadmap
- Roadmap board: https://github.com/users/9technologygroup/projects/1
- Roadmap board: https://github.com/orgs/PatchMon/projects/2
## License
@@ -278,6 +280,6 @@ Thank you to all our contributors who help make PatchMon better every day!
**Made with ❤️ by the PatchMon Team**
[![Discord](https://img.shields.io/badge/Discord-Join%20Server-blue?style=for-the-badge&logo=discord)](https://patchmon.net/discord)
[![GitHub](https://img.shields.io/badge/GitHub-Repository-black?style=for-the-badge&logo=github)](https://github.com/9technologygroup/patchmon.net)
[![GitHub](https://img.shields.io/badge/GitHub-Repository-black?style=for-the-badge&logo=github)](https://github.com/PatchMon/PatchMon)
</div>

View File

@@ -56,6 +56,28 @@ warning() {
log "WARNING: $1"
}
# Get or generate machine ID
get_machine_id() {
# Try standard locations for machine-id
if [[ -f /etc/machine-id ]]; then
cat /etc/machine-id
elif [[ -f /var/lib/dbus/machine-id ]]; then
cat /var/lib/dbus/machine-id
else
# Fallback: generate from hardware UUID or hostname+MAC
if command -v dmidecode &> /dev/null; then
local uuid=$(dmidecode -s system-uuid 2>/dev/null | tr -d ' -' | tr '[:upper:]' '[:lower:]')
if [[ -n "$uuid" && "$uuid" != "notpresent" ]]; then
echo "$uuid"
return
fi
fi
# Last resort: hash hostname + primary MAC address
local primary_mac=$(ip link show | grep -oP '(?<=link/ether\s)[0-9a-f:]+' | head -1 | tr -d ':')
echo "$HOSTNAME-$primary_mac" | sha256sum | cut -d' ' -f1 | cut -c1-32
fi
}
# Check if running as root
check_root() {
if [[ $EUID -ne 0 ]]; then
@@ -865,6 +887,9 @@ send_update() {
# Merge all JSON objects into one
local merged_json=$(echo "$hardware_json $network_json $system_json" | jq -s '.[0] * .[1] * .[2]')
# Get machine ID
local machine_id=$(get_machine_id)
# Create the base payload and merge with system info
local base_payload=$(cat <<EOF
{
@@ -875,7 +900,8 @@ send_update() {
"hostname": "$HOSTNAME",
"ip": "$IP_ADDRESS",
"architecture": "$ARCHITECTURE",
"agentVersion": "$AGENT_VERSION"
"agentVersion": "$AGENT_VERSION",
"machineId": "$machine_id"
}
EOF
)

View File

@@ -109,14 +109,39 @@ cleanup_old_files() {
# Run cleanup at start
cleanup_old_files
# Generate or retrieve machine ID
get_machine_id() {
# Try multiple sources for machine ID
if [[ -f /etc/machine-id ]]; then
cat /etc/machine-id
elif [[ -f /var/lib/dbus/machine-id ]]; then
cat /var/lib/dbus/machine-id
else
# Fallback: generate from hardware info (less ideal but works)
echo "patchmon-$(cat /sys/class/dmi/id/product_uuid 2>/dev/null || cat /proc/sys/kernel/random/uuid)"
fi
}
# Parse arguments from environment (passed via HTTP headers)
if [[ -z "$PATCHMON_URL" ]] || [[ -z "$API_ID" ]] || [[ -z "$API_KEY" ]]; then
error "Missing required parameters. This script should be called via the PatchMon web interface."
fi
# Check if --force flag is set (for bypassing broken packages)
FORCE_INSTALL="${FORCE_INSTALL:-false}"
if [[ "$*" == *"--force"* ]] || [[ "$FORCE_INSTALL" == "true" ]]; then
FORCE_INSTALL="true"
warning "⚠️ Force mode enabled - will bypass broken packages"
fi
# Get unique machine ID for this host
MACHINE_ID=$(get_machine_id)
export MACHINE_ID
info "🚀 Starting PatchMon Agent Installation..."
info "📋 Server: $PATCHMON_URL"
info "🔑 API ID: ${API_ID:0:16}..."
info "🆔 Machine ID: ${MACHINE_ID:0:16}..."
# Display diagnostic information
echo ""
@@ -131,16 +156,88 @@ echo ""
info "📦 Installing required dependencies..."
echo ""
# Function to check if a command exists
command_exists() {
command -v "$1" >/dev/null 2>&1
}
# Function to install packages with error handling
install_apt_packages() {
local packages=("$@")
local missing_packages=()
# Check which packages are missing
for pkg in "${packages[@]}"; do
if ! command_exists "$pkg"; then
missing_packages+=("$pkg")
fi
done
if [ ${#missing_packages[@]} -eq 0 ]; then
success "All required packages are already installed"
return 0
fi
info "Need to install: ${missing_packages[*]}"
# Build apt-get command based on force mode
local apt_cmd="apt-get install ${missing_packages[*]} -y"
if [[ "$FORCE_INSTALL" == "true" ]]; then
info "Using force mode - bypassing broken packages..."
apt_cmd="$apt_cmd -o APT::Get::Fix-Broken=false -o DPkg::Options::=\"--force-confold\" -o DPkg::Options::=\"--force-confdef\""
fi
# Try to install packages
if eval "$apt_cmd" 2>&1 | tee /tmp/patchmon_apt_install.log; then
success "Packages installed successfully"
return 0
else
warning "Package installation encountered issues, checking if required tools are available..."
# Verify critical dependencies are actually available
local all_ok=true
for pkg in "${packages[@]}"; do
if ! command_exists "$pkg"; then
if [[ "$FORCE_INSTALL" == "true" ]]; then
error "Critical dependency '$pkg' is not available even with --force. Please install manually."
else
error "Critical dependency '$pkg' is not available. Try again with --force flag or install manually: apt-get install $pkg"
fi
all_ok=false
fi
done
if $all_ok; then
success "All required tools are available despite installation warnings"
return 0
else
return 1
fi
fi
}
# Detect package manager and install jq and curl
if command -v apt-get >/dev/null 2>&1; then
# Debian/Ubuntu
info "Detected apt-get (Debian/Ubuntu)"
echo ""
# Check for broken packages
if dpkg -l | grep -q "^iH\|^iF" 2>/dev/null; then
if [[ "$FORCE_INSTALL" == "true" ]]; then
warning "Detected broken packages on system - force mode will work around them"
else
warning "⚠️ Broken packages detected on system"
warning "If installation fails, retry with: curl -s {URL}/api/v1/hosts/install --force -H ..."
fi
fi
info "Updating package lists..."
apt-get update
apt-get update || true
echo ""
info "Installing jq, curl, and bc..."
apt-get install jq curl bc -y
install_apt_packages jq curl bc
elif command -v yum >/dev/null 2>&1; then
# CentOS/RHEL 7
info "Detected yum (CentOS/RHEL 7)"
@@ -261,6 +358,33 @@ if [[ -f "/var/log/patchmon-agent.log" ]]; then
fi
# Step 4: Test the configuration
# Check if this machine is already enrolled
info "🔍 Checking if machine is already enrolled..."
existing_check=$(curl $CURL_FLAGS -s -X POST \
-H "X-API-ID: $API_ID" \
-H "X-API-KEY: $API_KEY" \
-H "Content-Type: application/json" \
-d "{\"machine_id\": \"$MACHINE_ID\"}" \
"$PATCHMON_URL/api/v1/hosts/check-machine-id" \
-w "\n%{http_code}" 2>&1)
http_code=$(echo "$existing_check" | tail -n 1)
response_body=$(echo "$existing_check" | sed '$d')
if [[ "$http_code" == "200" ]]; then
already_enrolled=$(echo "$response_body" | jq -r '.exists' 2>/dev/null || echo "false")
if [[ "$already_enrolled" == "true" ]]; then
warning "⚠️ This machine is already enrolled in PatchMon"
info "Machine ID: $MACHINE_ID"
info "Existing host: $(echo "$response_body" | jq -r '.host.friendly_name' 2>/dev/null)"
info ""
info "The agent will be reinstalled/updated with existing credentials."
echo ""
else
success "✅ Machine not yet enrolled - proceeding with installation"
fi
fi
info "🧪 Testing API credentials and connectivity..."
if /usr/local/bin/patchmon-agent.sh test; then
success "✅ TEST: API credentials are valid and server is reachable"

437
agents/proxmox_auto_enroll.sh Executable file
View File

@@ -0,0 +1,437 @@
#!/bin/bash
set -eo pipefail # Exit on error, pipe failures (removed -u as we handle unset vars explicitly)
# Trap to catch errors only (not normal exits)
trap 'echo "[ERROR] Script failed at line $LINENO with exit code $?"' ERR
SCRIPT_VERSION="2.0.0"
echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))"
# =============================================================================
# PatchMon Proxmox LXC Auto-Enrollment Script
# =============================================================================
# This script discovers LXC containers on a Proxmox host and automatically
# enrolls them into PatchMon for patch management.
#
# Usage:
# 1. Set environment variables or edit configuration below
# 2. Run: bash proxmox_auto_enroll.sh
#
# Requirements:
# - Must run on Proxmox host (requires 'pct' command)
# - Auto-enrollment token from PatchMon
# - Network access to PatchMon server
# =============================================================================
# ===== CONFIGURATION =====
PATCHMON_URL="${PATCHMON_URL:-https://patchmon.example.com}"
AUTO_ENROLLMENT_KEY="${AUTO_ENROLLMENT_KEY:-}"
AUTO_ENROLLMENT_SECRET="${AUTO_ENROLLMENT_SECRET:-}"
CURL_FLAGS="${CURL_FLAGS:--s}"
DRY_RUN="${DRY_RUN:-false}"
HOST_PREFIX="${HOST_PREFIX:-}"
SKIP_STOPPED="${SKIP_STOPPED:-true}"
PARALLEL_INSTALL="${PARALLEL_INSTALL:-false}"
MAX_PARALLEL="${MAX_PARALLEL:-5}"
FORCE_INSTALL="${FORCE_INSTALL:-false}"
# ===== COLOR OUTPUT =====
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# ===== LOGGING FUNCTIONS =====
info() { echo -e "${GREEN}[INFO]${NC} $1"; return 0; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; return 0; }
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; return 0; }
debug() { [[ "${DEBUG:-false}" == "true" ]] && echo -e "${BLUE}[DEBUG]${NC} $1" || true; return 0; }
# ===== BANNER =====
cat << "EOF"
╔═══════════════════════════════════════════════════════════════╗
║ ║
║ ____ _ _ __ __ ║
| _ \ __ _| |_ ___| |__ | \/ | ___ _ __ ║
| |_) / _` | __/ __| '_ \| |\/| |/ _ \| '_ \
| __/ (_| | || (__| | | | | | | (_) | | | |
|_| \__,_|\__\___|_| |_|_| |_|\___/|_| |_|
║ ║
║ Proxmox LXC Auto-Enrollment Script ║
║ ║
╚═══════════════════════════════════════════════════════════════╝
EOF
echo ""
# ===== VALIDATION =====
info "Validating configuration..."
if [[ -z "$AUTO_ENROLLMENT_KEY" ]] || [[ -z "$AUTO_ENROLLMENT_SECRET" ]]; then
error "AUTO_ENROLLMENT_KEY and AUTO_ENROLLMENT_SECRET must be set"
fi
if [[ -z "$PATCHMON_URL" ]]; then
error "PATCHMON_URL must be set"
fi
# Check if running on Proxmox
if ! command -v pct &> /dev/null; then
error "This script must run on a Proxmox host (pct command not found)"
fi
# Check for required commands
for cmd in curl jq; do
if ! command -v $cmd &> /dev/null; then
error "Required command '$cmd' not found. Please install it first."
fi
done
info "Configuration validated successfully"
info "PatchMon Server: $PATCHMON_URL"
info "Dry Run Mode: $DRY_RUN"
info "Skip Stopped Containers: $SKIP_STOPPED"
echo ""
# ===== DISCOVER LXC CONTAINERS =====
info "Discovering LXC containers..."
lxc_list=$(pct list | tail -n +2) # Skip header
if [[ -z "$lxc_list" ]]; then
warn "No LXC containers found on this Proxmox host"
exit 0
fi
# Count containers
total_containers=$(echo "$lxc_list" | wc -l)
info "Found $total_containers LXC container(s)"
echo ""
info "Initializing statistics..."
# ===== STATISTICS =====
enrolled_count=0
skipped_count=0
failed_count=0
# Track containers with dpkg errors for later recovery
declare -A dpkg_error_containers
# Track all failed containers for summary
declare -A failed_containers
info "Statistics initialized"
# ===== PROCESS CONTAINERS =====
info "Starting container processing loop..."
while IFS= read -r line; do
info "[DEBUG] Read line from lxc_list"
vmid=$(echo "$line" | awk '{print $1}')
status=$(echo "$line" | awk '{print $2}')
name=$(echo "$line" | awk '{print $3}')
info "Processing LXC $vmid: $name (status: $status)"
# Skip stopped containers if configured
if [[ "$status" != "running" ]] && [[ "$SKIP_STOPPED" == "true" ]]; then
warn " Skipping $name - container not running"
((skipped_count++)) || true
echo ""
continue
fi
# Check if container is stopped
if [[ "$status" != "running" ]]; then
warn " Container $name is stopped - cannot gather info or install agent"
((skipped_count++)) || true
echo ""
continue
fi
# Get container details
debug " Gathering container information..."
hostname=$(timeout 5 pct exec "$vmid" -- hostname 2>/dev/null </dev/null || echo "$name")
ip_address=$(timeout 5 pct exec "$vmid" -- hostname -I 2>/dev/null </dev/null | awk '{print $1}' || echo "unknown")
os_info=$(timeout 5 pct exec "$vmid" -- cat /etc/os-release 2>/dev/null </dev/null | grep "^PRETTY_NAME=" | cut -d'"' -f2 || echo "unknown")
# Get machine ID from container
machine_id=$(timeout 5 pct exec "$vmid" -- bash -c "cat /etc/machine-id 2>/dev/null || cat /var/lib/dbus/machine-id 2>/dev/null || echo 'proxmox-lxc-$vmid-'$(cat /proc/sys/kernel/random/uuid)" </dev/null 2>/dev/null || echo "proxmox-lxc-$vmid-unknown")
friendly_name="${HOST_PREFIX}${hostname}"
info " Hostname: $hostname"
info " IP Address: $ip_address"
info " OS: $os_info"
info " Machine ID: ${machine_id:0:16}..."
if [[ "$DRY_RUN" == "true" ]]; then
info " [DRY RUN] Would enroll: $friendly_name"
((enrolled_count++)) || true
echo ""
continue
fi
# Call PatchMon auto-enrollment API
info " Enrolling $friendly_name in PatchMon..."
response=$(curl $CURL_FLAGS -X POST \
-H "X-Auto-Enrollment-Key: $AUTO_ENROLLMENT_KEY" \
-H "X-Auto-Enrollment-Secret: $AUTO_ENROLLMENT_SECRET" \
-H "Content-Type: application/json" \
-d "{
\"friendly_name\": \"$friendly_name\",
\"machine_id\": \"$machine_id\",
\"metadata\": {
\"vmid\": \"$vmid\",
\"proxmox_node\": \"$(hostname)\",
\"ip_address\": \"$ip_address\",
\"os_info\": \"$os_info\"
}
}" \
"$PATCHMON_URL/api/v1/auto-enrollment/enroll" \
-w "\n%{http_code}" 2>&1)
http_code=$(echo "$response" | tail -n 1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" == "201" ]]; then
api_id=$(echo "$body" | jq -r '.host.api_id' 2>/dev/null || echo "")
api_key=$(echo "$body" | jq -r '.host.api_key' 2>/dev/null || echo "")
if [[ -z "$api_id" ]] || [[ -z "$api_key" ]]; then
error " Failed to parse API credentials from response"
fi
info " ✓ Host enrolled successfully: $api_id"
# Ensure curl is installed in the container
info " Checking for curl in container..."
curl_check=$(timeout 10 pct exec "$vmid" -- bash -c "command -v curl >/dev/null 2>&1 && echo 'installed' || echo 'missing'" 2>/dev/null </dev/null || echo "error")
if [[ "$curl_check" == "missing" ]]; then
info " Installing curl in container..."
# Detect package manager and install curl
curl_install_output=$(timeout 60 pct exec "$vmid" -- bash -c "
if command -v apt-get >/dev/null 2>&1; then
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq && apt-get install -y -qq curl
elif command -v yum >/dev/null 2>&1; then
yum install -y -q curl
elif command -v dnf >/dev/null 2>&1; then
dnf install -y -q curl
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache curl
else
echo 'ERROR: No supported package manager found'
exit 1
fi
" 2>&1 </dev/null) || true
if [[ "$curl_install_output" == *"ERROR: No supported package manager"* ]]; then
warn " ✗ Could not install curl - no supported package manager found"
failed_containers["$vmid"]="$friendly_name|No package manager for curl|$curl_install_output"
((failed_count++)) || true
echo ""
sleep 1
continue
else
info " ✓ curl installed successfully"
fi
else
info " ✓ curl already installed"
fi
# Install PatchMon agent in container
info " Installing PatchMon agent..."
# Build install URL with force flag if enabled
install_url="$PATCHMON_URL/api/v1/hosts/install"
if [[ "$FORCE_INSTALL" == "true" ]]; then
install_url="$install_url?force=true"
info " Using force mode - will bypass broken packages"
fi
# Reset exit code for this container
install_exit_code=0
# Download and execute in separate steps to avoid stdin issues with piping
install_output=$(timeout 180 pct exec "$vmid" -- bash -c "
cd /tmp
curl $CURL_FLAGS \
-H \"X-API-ID: $api_id\" \
-H \"X-API-KEY: $api_key\" \
-o patchmon-install.sh \
'$install_url' && \
bash patchmon-install.sh && \
rm -f patchmon-install.sh
" 2>&1 </dev/null) || install_exit_code=$?
# Check both exit code AND success message in output for reliability
if [[ $install_exit_code -eq 0 ]] || [[ "$install_output" == *"PatchMon Agent installation completed successfully"* ]]; then
info " ✓ Agent installed successfully in $friendly_name"
((enrolled_count++)) || true
elif [[ $install_exit_code -eq 124 ]]; then
warn " ⏱ Agent installation timed out (>180s) in $friendly_name"
info " Install output: $install_output"
# Store failure details
failed_containers["$vmid"]="$friendly_name|Timeout (>180s)|$install_output"
((failed_count++)) || true
else
# Check if it's a dpkg error
if [[ "$install_output" == *"dpkg was interrupted"* ]] || [[ "$install_output" == *"dpkg --configure -a"* ]]; then
warn " ⚠ Failed due to dpkg error in $friendly_name (can be fixed)"
dpkg_error_containers["$vmid"]="$friendly_name:$api_id:$api_key"
# Store failure details
failed_containers["$vmid"]="$friendly_name|dpkg error|$install_output"
else
warn " ✗ Failed to install agent in $friendly_name (exit: $install_exit_code)"
# Store failure details
failed_containers["$vmid"]="$friendly_name|Exit code $install_exit_code|$install_output"
fi
info " Install output: $install_output"
((failed_count++)) || true
fi
elif [[ "$http_code" == "409" ]]; then
warn " ⊘ Host $friendly_name already enrolled - skipping"
((skipped_count++)) || true
elif [[ "$http_code" == "429" ]]; then
error " ✗ Rate limit exceeded - maximum hosts per day reached"
failed_containers["$vmid"]="$friendly_name|Rate limit exceeded|$body"
((failed_count++)) || true
else
error " ✗ Failed to enroll $friendly_name - HTTP $http_code"
debug " Response: $body"
failed_containers["$vmid"]="$friendly_name|HTTP $http_code enrollment failed|$body"
((failed_count++)) || true
fi
echo ""
sleep 1 # Rate limiting between containers
done <<< "$lxc_list"
# ===== SUMMARY =====
echo ""
echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ ENROLLMENT SUMMARY ║"
echo "╚═══════════════════════════════════════════════════════════════╝"
echo ""
info "Total Containers Found: $total_containers"
info "Successfully Enrolled: $enrolled_count"
info "Skipped: $skipped_count"
info "Failed: $failed_count"
echo ""
# ===== FAILURE DETAILS =====
if [[ ${#failed_containers[@]} -gt 0 ]]; then
echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ FAILURE DETAILS ║"
echo "╚═══════════════════════════════════════════════════════════════╝"
echo ""
for vmid in "${!failed_containers[@]}"; do
IFS='|' read -r name reason output <<< "${failed_containers[$vmid]}"
warn "Container $vmid: $name"
info " Reason: $reason"
info " Last 5 lines of output:"
# Get last 5 lines of output
last_5_lines=$(echo "$output" | tail -n 5)
# Display each line with proper indentation
while IFS= read -r line; do
echo " $line"
done <<< "$last_5_lines"
echo ""
done
fi
if [[ "$DRY_RUN" == "true" ]]; then
warn "This was a DRY RUN - no actual changes were made"
warn "Set DRY_RUN=false to perform actual enrollment"
fi
# ===== DPKG ERROR RECOVERY =====
if [[ ${#dpkg_error_containers[@]} -gt 0 ]]; then
echo ""
echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ DPKG ERROR RECOVERY AVAILABLE ║"
echo "╚═══════════════════════════════════════════════════════════════╝"
echo ""
warn "Detected ${#dpkg_error_containers[@]} container(s) with dpkg errors:"
for vmid in "${!dpkg_error_containers[@]}"; do
IFS=':' read -r name api_id api_key <<< "${dpkg_error_containers[$vmid]}"
info " • Container $vmid: $name"
done
echo ""
# Ask user if they want to fix dpkg errors
read -p "Would you like to fix dpkg errors and retry installation? (y/N): " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo ""
info "Starting dpkg recovery process..."
echo ""
recovered_count=0
for vmid in "${!dpkg_error_containers[@]}"; do
IFS=':' read -r name api_id api_key <<< "${dpkg_error_containers[$vmid]}"
info "Fixing dpkg in container $vmid ($name)..."
# Run dpkg --configure -a
dpkg_output=$(timeout 60 pct exec "$vmid" -- dpkg --configure -a 2>&1 </dev/null || true)
if [[ $? -eq 0 ]]; then
info " ✓ dpkg fixed successfully"
# Retry agent installation
info " Retrying agent installation..."
install_exit_code=0
install_output=$(timeout 180 pct exec "$vmid" -- bash -c "
cd /tmp
curl $CURL_FLAGS \
-H \"X-API-ID: $api_id\" \
-H \"X-API-KEY: $api_key\" \
-o patchmon-install.sh \
'$PATCHMON_URL/api/v1/hosts/install' && \
bash patchmon-install.sh && \
rm -f patchmon-install.sh
" 2>&1 </dev/null) || install_exit_code=$?
if [[ $install_exit_code -eq 0 ]] || [[ "$install_output" == *"PatchMon Agent installation completed successfully"* ]]; then
info " ✓ Agent installed successfully in $name"
((recovered_count++)) || true
((enrolled_count++)) || true
((failed_count--)) || true
else
warn " ✗ Agent installation still failed (exit: $install_exit_code)"
fi
else
warn " ✗ Failed to fix dpkg in $name"
info " dpkg output: $dpkg_output"
fi
echo ""
done
echo ""
info "Recovery complete: $recovered_count container(s) recovered"
echo ""
fi
fi
if [[ $failed_count -gt 0 ]]; then
warn "Some containers failed to enroll. Check the logs above for details."
exit 1
fi
info "Auto-enrollment complete! ✓"
exit 0

View File

@@ -0,0 +1,37 @@
-- CreateTable
CREATE TABLE "auto_enrollment_tokens" (
"id" TEXT NOT NULL,
"token_name" TEXT NOT NULL,
"token_key" TEXT NOT NULL,
"token_secret" TEXT NOT NULL,
"created_by_user_id" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"allowed_ip_ranges" TEXT[],
"max_hosts_per_day" INTEGER NOT NULL DEFAULT 100,
"hosts_created_today" INTEGER NOT NULL DEFAULT 0,
"last_reset_date" DATE NOT NULL DEFAULT CURRENT_TIMESTAMP,
"default_host_group_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"last_used_at" TIMESTAMP(3),
"expires_at" TIMESTAMP(3),
"metadata" JSONB,
CONSTRAINT "auto_enrollment_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "auto_enrollment_tokens_token_key_key" ON "auto_enrollment_tokens"("token_key");
-- CreateIndex
CREATE INDEX "auto_enrollment_tokens_token_key_idx" ON "auto_enrollment_tokens"("token_key");
-- CreateIndex
CREATE INDEX "auto_enrollment_tokens_is_active_idx" ON "auto_enrollment_tokens"("is_active");
-- AddForeignKey
ALTER TABLE "auto_enrollment_tokens" ADD CONSTRAINT "auto_enrollment_tokens_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "auto_enrollment_tokens" ADD CONSTRAINT "auto_enrollment_tokens_default_host_group_id_fkey" FOREIGN KEY ("default_host_group_id") REFERENCES "host_groups"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,20 @@
-- Add machine_id column as nullable first
ALTER TABLE "hosts" ADD COLUMN "machine_id" TEXT;
-- Generate machine_ids for existing hosts using their API ID as a fallback
UPDATE "hosts" SET "machine_id" = 'migrated-' || "api_id" WHERE "machine_id" IS NULL;
-- Remove the unique constraint from friendly_name
ALTER TABLE "hosts" DROP CONSTRAINT IF EXISTS "hosts_friendly_name_key";
-- Also drop the unique index if it exists (constraint and index can exist separately)
DROP INDEX IF EXISTS "hosts_friendly_name_key";
-- Now make machine_id NOT NULL and add unique constraint
ALTER TABLE "hosts" ALTER COLUMN "machine_id" SET NOT NULL;
ALTER TABLE "hosts" ADD CONSTRAINT "hosts_machine_id_key" UNIQUE ("machine_id");
-- Create indexes for better query performance
CREATE INDEX "hosts_machine_id_idx" ON "hosts"("machine_id");
CREATE INDEX "hosts_friendly_name_idx" ON "hosts"("friendly_name");

View File

@@ -0,0 +1,4 @@
-- AddLogoFieldsToSettings
ALTER TABLE "settings" ADD COLUMN "logo_dark" VARCHAR(255) DEFAULT '/assets/logo_dark.png';
ALTER TABLE "settings" ADD COLUMN "logo_light" VARCHAR(255) DEFAULT '/assets/logo_light.png';
ALTER TABLE "settings" ADD COLUMN "favicon" VARCHAR(255) DEFAULT '/assets/logo_square.svg';

View File

@@ -21,13 +21,14 @@ model dashboard_preferences {
}
model host_groups {
id String @id
name String @unique
description String?
color String? @default("#3B82F6")
created_at DateTime @default(now())
updated_at DateTime
hosts hosts[]
id String @id
name String @unique
description String?
color String? @default("#3B82F6")
created_at DateTime @default(now())
updated_at DateTime
hosts hosts[]
auto_enrollment_tokens auto_enrollment_tokens[]
}
model host_packages {
@@ -59,7 +60,8 @@ model host_repositories {
model hosts {
id String @id
friendly_name String @unique
machine_id String @unique
friendly_name String
ip String?
os_type String
os_version String
@@ -91,6 +93,10 @@ model hosts {
host_repositories host_repositories[]
host_groups host_groups? @relation(fields: [host_group_id], references: [id])
update_history update_history[]
@@index([machine_id])
@@index([friendly_name])
@@index([hostname])
}
model packages {
@@ -158,6 +164,9 @@ model settings {
signup_enabled Boolean @default(false)
default_user_role String @default("user")
ignore_ssl_self_signed Boolean @default(false)
logo_dark String? @default("/assets/logo_dark.png")
logo_light String? @default("/assets/logo_light.png")
favicon String? @default("/assets/logo_square.svg")
}
model update_history {
@@ -172,22 +181,23 @@ model update_history {
}
model users {
id String @id
username String @unique
email String @unique
password_hash String
role String @default("admin")
is_active Boolean @default(true)
last_login DateTime?
created_at DateTime @default(now())
updated_at DateTime
tfa_backup_codes String?
tfa_enabled Boolean @default(false)
tfa_secret String?
first_name String?
last_name String?
dashboard_preferences dashboard_preferences[]
user_sessions user_sessions[]
id String @id
username String @unique
email String @unique
password_hash String
role String @default("admin")
is_active Boolean @default(true)
last_login DateTime?
created_at DateTime @default(now())
updated_at DateTime
tfa_backup_codes String?
tfa_enabled Boolean @default(false)
tfa_secret String?
first_name String?
last_name String?
dashboard_preferences dashboard_preferences[]
user_sessions user_sessions[]
auto_enrollment_tokens auto_enrollment_tokens[]
}
model user_sessions {
@@ -207,3 +217,27 @@ model user_sessions {
@@index([refresh_token])
@@index([expires_at])
}
model auto_enrollment_tokens {
id String @id
token_name String
token_key String @unique
token_secret String
created_by_user_id String?
is_active Boolean @default(true)
allowed_ip_ranges String[]
max_hosts_per_day Int @default(100)
hosts_created_today Int @default(0)
last_reset_date DateTime @default(now()) @db.Date
default_host_group_id String?
created_at DateTime @default(now())
updated_at DateTime
last_used_at DateTime?
expires_at DateTime?
metadata Json?
users users? @relation(fields: [created_by_user_id], references: [id], onDelete: SetNull)
host_groups host_groups? @relation(fields: [default_host_group_id], references: [id], onDelete: SetNull)
@@index([token_key])
@@index([is_active])
}

View File

@@ -18,10 +18,10 @@ const authenticateToken = async (req, res, next) => {
}
// Verify token
const decoded = jwt.verify(
token,
process.env.JWT_SECRET || "your-secret-key",
);
if (!process.env.JWT_SECRET) {
throw new Error("JWT_SECRET environment variable is required");
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Validate session and check inactivity timeout
const validation = await validate_session(decoded.sessionId, token);
@@ -85,10 +85,10 @@ const optionalAuth = async (req, _res, next) => {
const token = authHeader?.split(" ")[1];
if (token) {
const decoded = jwt.verify(
token,
process.env.JWT_SECRET || "your-secret-key",
);
if (!process.env.JWT_SECRET) {
throw new Error("JWT_SECRET environment variable is required");
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await prisma.users.findUnique({
where: { id: decoded.userId },
select: {

View File

@@ -156,7 +156,10 @@ router.post(
// Generate JWT token
const generateToken = (userId) => {
return jwt.sign({ userId }, process.env.JWT_SECRET || "your-secret-key", {
if (!process.env.JWT_SECRET) {
throw new Error("JWT_SECRET environment variable is required");
}
return jwt.sign({ userId }, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRES_IN || "24h",
});
};

View File

@@ -0,0 +1,745 @@
const express = require("express");
const { PrismaClient } = require("@prisma/client");
const crypto = require("node:crypto");
const bcrypt = require("bcryptjs");
const { body, validationResult } = require("express-validator");
const { authenticateToken } = require("../middleware/auth");
const { requireManageSettings } = require("../middleware/permissions");
const { v4: uuidv4 } = require("uuid");
const router = express.Router();
const prisma = new PrismaClient();
// Generate auto-enrollment token credentials
const generate_auto_enrollment_token = () => {
const token_key = `patchmon_ae_${crypto.randomBytes(16).toString("hex")}`;
const token_secret = crypto.randomBytes(48).toString("hex");
return { token_key, token_secret };
};
// Middleware to validate auto-enrollment token
const validate_auto_enrollment_token = async (req, res, next) => {
try {
const token_key = req.headers["x-auto-enrollment-key"];
const token_secret = req.headers["x-auto-enrollment-secret"];
if (!token_key || !token_secret) {
return res
.status(401)
.json({ error: "Auto-enrollment credentials required" });
}
// Find token
const token = await prisma.auto_enrollment_tokens.findUnique({
where: { token_key: token_key },
});
if (!token || !token.is_active) {
return res.status(401).json({ error: "Invalid or inactive token" });
}
// Verify secret (hashed)
const is_valid = await bcrypt.compare(token_secret, token.token_secret);
if (!is_valid) {
return res.status(401).json({ error: "Invalid token secret" });
}
// Check expiration
if (token.expires_at && new Date() > new Date(token.expires_at)) {
return res.status(401).json({ error: "Token expired" });
}
// Check IP whitelist if configured
if (token.allowed_ip_ranges && token.allowed_ip_ranges.length > 0) {
const client_ip = req.ip || req.connection.remoteAddress;
// Basic IP check - can be enhanced with CIDR matching
const ip_allowed = token.allowed_ip_ranges.some((allowed_ip) => {
return client_ip.includes(allowed_ip);
});
if (!ip_allowed) {
console.warn(
`Auto-enrollment attempt from unauthorized IP: ${client_ip}`,
);
return res
.status(403)
.json({ error: "IP address not authorized for this token" });
}
}
// Check rate limit (hosts per day)
const today = new Date().toISOString().split("T")[0];
const token_reset_date = token.last_reset_date.toISOString().split("T")[0];
if (token_reset_date !== today) {
// Reset daily counter
await prisma.auto_enrollment_tokens.update({
where: { id: token.id },
data: {
hosts_created_today: 0,
last_reset_date: new Date(),
updated_at: new Date(),
},
});
token.hosts_created_today = 0;
}
if (token.hosts_created_today >= token.max_hosts_per_day) {
return res.status(429).json({
error: "Rate limit exceeded",
message: `Maximum ${token.max_hosts_per_day} hosts per day allowed for this token`,
});
}
req.auto_enrollment_token = token;
next();
} catch (error) {
console.error("Auto-enrollment token validation error:", error);
res.status(500).json({ error: "Token validation failed" });
}
};
// ========== ADMIN ENDPOINTS (Manage Tokens) ==========
// Create auto-enrollment token
router.post(
"/tokens",
authenticateToken,
requireManageSettings,
[
body("token_name")
.isLength({ min: 1, max: 255 })
.withMessage("Token name is required (max 255 characters)"),
body("allowed_ip_ranges")
.optional()
.isArray()
.withMessage("Allowed IP ranges must be an array"),
body("max_hosts_per_day")
.optional()
.isInt({ min: 1, max: 1000 })
.withMessage("Max hosts per day must be between 1 and 1000"),
body("default_host_group_id")
.optional({ nullable: true, checkFalsy: true })
.isString(),
body("expires_at")
.optional({ nullable: true, checkFalsy: true })
.isISO8601()
.withMessage("Invalid date format"),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const {
token_name,
allowed_ip_ranges = [],
max_hosts_per_day = 100,
default_host_group_id,
expires_at,
metadata = {},
} = req.body;
// Validate host group if provided
if (default_host_group_id) {
const host_group = await prisma.host_groups.findUnique({
where: { id: default_host_group_id },
});
if (!host_group) {
return res.status(400).json({ error: "Host group not found" });
}
}
const { token_key, token_secret } = generate_auto_enrollment_token();
const hashed_secret = await bcrypt.hash(token_secret, 10);
const token = await prisma.auto_enrollment_tokens.create({
data: {
id: uuidv4(),
token_name,
token_key: token_key,
token_secret: hashed_secret,
created_by_user_id: req.user.id,
allowed_ip_ranges,
max_hosts_per_day,
default_host_group_id: default_host_group_id || null,
expires_at: expires_at ? new Date(expires_at) : null,
metadata: { integration_type: "proxmox-lxc", ...metadata },
updated_at: new Date(),
},
include: {
host_groups: {
select: {
id: true,
name: true,
color: true,
},
},
users: {
select: {
id: true,
username: true,
first_name: true,
last_name: true,
},
},
},
});
// Return unhashed secret ONLY once (like API keys)
res.status(201).json({
message: "Auto-enrollment token created successfully",
token: {
id: token.id,
token_name: token.token_name,
token_key: token_key,
token_secret: token_secret, // ONLY returned here!
max_hosts_per_day: token.max_hosts_per_day,
default_host_group: token.host_groups,
created_by: token.users,
expires_at: token.expires_at,
},
warning: "⚠️ Save the token_secret now - it cannot be retrieved later!",
});
} catch (error) {
console.error("Create auto-enrollment token error:", error);
res.status(500).json({ error: "Failed to create token" });
}
},
);
// List auto-enrollment tokens
router.get(
"/tokens",
authenticateToken,
requireManageSettings,
async (_req, res) => {
try {
const tokens = await prisma.auto_enrollment_tokens.findMany({
select: {
id: true,
token_name: true,
token_key: true,
is_active: true,
allowed_ip_ranges: true,
max_hosts_per_day: true,
hosts_created_today: true,
last_used_at: true,
expires_at: true,
created_at: true,
default_host_group_id: true,
metadata: true,
host_groups: {
select: {
id: true,
name: true,
color: true,
},
},
users: {
select: {
id: true,
username: true,
first_name: true,
last_name: true,
},
},
},
orderBy: { created_at: "desc" },
});
res.json(tokens);
} catch (error) {
console.error("List auto-enrollment tokens error:", error);
res.status(500).json({ error: "Failed to list tokens" });
}
},
);
// Get single token details
router.get(
"/tokens/:tokenId",
authenticateToken,
requireManageSettings,
async (req, res) => {
try {
const { tokenId } = req.params;
const token = await prisma.auto_enrollment_tokens.findUnique({
where: { id: tokenId },
include: {
host_groups: {
select: {
id: true,
name: true,
color: true,
},
},
users: {
select: {
id: true,
username: true,
first_name: true,
last_name: true,
},
},
},
});
if (!token) {
return res.status(404).json({ error: "Token not found" });
}
// Don't include the secret in response
const { token_secret: _secret, ...token_data } = token;
res.json(token_data);
} catch (error) {
console.error("Get token error:", error);
res.status(500).json({ error: "Failed to get token" });
}
},
);
// Update token (toggle active state, update limits, etc.)
router.patch(
"/tokens/:tokenId",
authenticateToken,
requireManageSettings,
[
body("is_active").optional().isBoolean(),
body("max_hosts_per_day").optional().isInt({ min: 1, max: 1000 }),
body("allowed_ip_ranges").optional().isArray(),
body("expires_at").optional().isISO8601(),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { tokenId } = req.params;
const update_data = { updated_at: new Date() };
if (req.body.is_active !== undefined)
update_data.is_active = req.body.is_active;
if (req.body.max_hosts_per_day !== undefined)
update_data.max_hosts_per_day = req.body.max_hosts_per_day;
if (req.body.allowed_ip_ranges !== undefined)
update_data.allowed_ip_ranges = req.body.allowed_ip_ranges;
if (req.body.expires_at !== undefined)
update_data.expires_at = new Date(req.body.expires_at);
const token = await prisma.auto_enrollment_tokens.update({
where: { id: tokenId },
data: update_data,
include: {
host_groups: true,
users: {
select: {
username: true,
first_name: true,
last_name: true,
},
},
},
});
const { token_secret: _secret, ...token_data } = token;
res.json({
message: "Token updated successfully",
token: token_data,
});
} catch (error) {
console.error("Update token error:", error);
res.status(500).json({ error: "Failed to update token" });
}
},
);
// Delete token
router.delete(
"/tokens/:tokenId",
authenticateToken,
requireManageSettings,
async (req, res) => {
try {
const { tokenId } = req.params;
const token = await prisma.auto_enrollment_tokens.findUnique({
where: { id: tokenId },
});
if (!token) {
return res.status(404).json({ error: "Token not found" });
}
await prisma.auto_enrollment_tokens.delete({
where: { id: tokenId },
});
res.json({
message: "Auto-enrollment token deleted successfully",
deleted_token: {
id: token.id,
token_name: token.token_name,
},
});
} catch (error) {
console.error("Delete token error:", error);
res.status(500).json({ error: "Failed to delete token" });
}
},
);
// ========== AUTO-ENROLLMENT ENDPOINTS (Used by Scripts) ==========
// Future integrations can follow this pattern:
// - /proxmox-lxc - Proxmox LXC containers
// - /vmware-esxi - VMware ESXi VMs
// - /docker - Docker containers
// - /kubernetes - Kubernetes pods
// - /aws-ec2 - AWS EC2 instances
// Serve the Proxmox LXC enrollment script with credentials injected
router.get("/proxmox-lxc", async (req, res) => {
try {
// Get token from query params
const token_key = req.query.token_key;
const token_secret = req.query.token_secret;
if (!token_key || !token_secret) {
return res
.status(401)
.json({ error: "Token key and secret required as query parameters" });
}
// Validate token
const token = await prisma.auto_enrollment_tokens.findUnique({
where: { token_key: token_key },
});
if (!token || !token.is_active) {
return res.status(401).json({ error: "Invalid or inactive token" });
}
// Verify secret
const is_valid = await bcrypt.compare(token_secret, token.token_secret);
if (!is_valid) {
return res.status(401).json({ error: "Invalid token secret" });
}
// Check expiration
if (token.expires_at && new Date() > new Date(token.expires_at)) {
return res.status(401).json({ error: "Token expired" });
}
const fs = require("node:fs");
const path = require("node:path");
const script_path = path.join(
__dirname,
"../../../agents/proxmox_auto_enroll.sh",
);
if (!fs.existsSync(script_path)) {
return res
.status(404)
.json({ error: "Proxmox enrollment script not found" });
}
let script = fs.readFileSync(script_path, "utf8");
// Convert Windows line endings to Unix line endings
script = script.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// Get the configured server URL from settings
let server_url = "http://localhost:3001";
try {
const settings = await prisma.settings.findFirst();
if (settings?.server_url) {
server_url = settings.server_url;
}
} catch (settings_error) {
console.warn(
"Could not fetch settings, using default server URL:",
settings_error.message,
);
}
// Determine curl flags dynamically from settings
let curl_flags = "-s";
try {
const settings = await prisma.settings.findFirst();
if (settings && settings.ignore_ssl_self_signed === true) {
curl_flags = "-sk";
}
} catch (_) {}
// Check for --force parameter
const force_install = req.query.force === "true" || req.query.force === "1";
// Inject the token credentials, server URL, curl flags, and force flag into the script
const env_vars = `#!/bin/bash
# PatchMon Auto-Enrollment Configuration (Auto-generated)
export PATCHMON_URL="${server_url}"
export AUTO_ENROLLMENT_KEY="${token.token_key}"
export AUTO_ENROLLMENT_SECRET="${token_secret}"
export CURL_FLAGS="${curl_flags}"
export FORCE_INSTALL="${force_install ? "true" : "false"}"
`;
// Remove the shebang and configuration section from the original script
script = script.replace(/^#!/, "#");
// Remove the configuration section (between # ===== CONFIGURATION ===== and the next # =====)
script = script.replace(
/# ===== CONFIGURATION =====[\s\S]*?(?=# ===== COLOR OUTPUT =====)/,
"",
);
script = env_vars + script;
res.setHeader("Content-Type", "text/plain");
res.setHeader(
"Content-Disposition",
'inline; filename="proxmox_auto_enroll.sh"',
);
res.send(script);
} catch (error) {
console.error("Proxmox script serve error:", error);
res.status(500).json({ error: "Failed to serve enrollment script" });
}
});
// Create host via auto-enrollment
router.post(
"/enroll",
validate_auto_enrollment_token,
[
body("friendly_name")
.isLength({ min: 1, max: 255 })
.withMessage("Friendly name is required"),
body("machine_id")
.isLength({ min: 1, max: 255 })
.withMessage("Machine ID is required"),
body("metadata").optional().isObject(),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { friendly_name, machine_id } = req.body;
// Generate host API credentials
const api_id = `patchmon_${crypto.randomBytes(8).toString("hex")}`;
const api_key = crypto.randomBytes(32).toString("hex");
// Check if host already exists by machine_id (not hostname)
const existing_host = await prisma.hosts.findUnique({
where: { machine_id },
});
if (existing_host) {
return res.status(409).json({
error: "Host already exists",
host_id: existing_host.id,
api_id: existing_host.api_id,
machine_id: existing_host.machine_id,
friendly_name: existing_host.friendly_name,
message:
"This machine is already enrolled in PatchMon (matched by machine ID)",
});
}
// Create host
const host = await prisma.hosts.create({
data: {
id: uuidv4(),
machine_id,
friendly_name,
os_type: "unknown",
os_version: "unknown",
api_id: api_id,
api_key: api_key,
host_group_id: req.auto_enrollment_token.default_host_group_id,
status: "pending",
notes: `Auto-enrolled via ${req.auto_enrollment_token.token_name} on ${new Date().toISOString()}`,
updated_at: new Date(),
},
include: {
host_groups: {
select: {
id: true,
name: true,
color: true,
},
},
},
});
// Update token usage stats
await prisma.auto_enrollment_tokens.update({
where: { id: req.auto_enrollment_token.id },
data: {
hosts_created_today: { increment: 1 },
last_used_at: new Date(),
updated_at: new Date(),
},
});
console.log(
`Auto-enrolled host: ${friendly_name} (${host.id}) via token: ${req.auto_enrollment_token.token_name}`,
);
res.status(201).json({
message: "Host enrolled successfully",
host: {
id: host.id,
friendly_name: host.friendly_name,
api_id: api_id,
api_key: api_key,
host_group: host.host_groups,
status: host.status,
},
});
} catch (error) {
console.error("Auto-enrollment error:", error);
res.status(500).json({ error: "Failed to enroll host" });
}
},
);
// Bulk enroll multiple hosts at once
router.post(
"/enroll/bulk",
validate_auto_enrollment_token,
[
body("hosts")
.isArray({ min: 1, max: 50 })
.withMessage("Hosts array required (max 50)"),
body("hosts.*.friendly_name")
.isLength({ min: 1 })
.withMessage("Each host needs a friendly_name"),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { hosts } = req.body;
// Check rate limit
const remaining_quota =
req.auto_enrollment_token.max_hosts_per_day -
req.auto_enrollment_token.hosts_created_today;
if (hosts.length > remaining_quota) {
return res.status(429).json({
error: "Rate limit exceeded",
message: `Only ${remaining_quota} hosts remaining in daily quota`,
});
}
const results = {
success: [],
failed: [],
skipped: [],
};
for (const host_data of hosts) {
try {
const { friendly_name, machine_id } = host_data;
if (!machine_id) {
results.failed.push({
friendly_name,
error: "Machine ID is required",
});
continue;
}
// Check if host already exists by machine_id
const existing_host = await prisma.hosts.findUnique({
where: { machine_id },
});
if (existing_host) {
results.skipped.push({
friendly_name,
machine_id,
reason: "Machine already enrolled",
api_id: existing_host.api_id,
});
continue;
}
// Generate credentials
const api_id = `patchmon_${crypto.randomBytes(8).toString("hex")}`;
const api_key = crypto.randomBytes(32).toString("hex");
// Create host
const host = await prisma.hosts.create({
data: {
id: uuidv4(),
machine_id,
friendly_name,
os_type: "unknown",
os_version: "unknown",
api_id: api_id,
api_key: api_key,
host_group_id: req.auto_enrollment_token.default_host_group_id,
status: "pending",
notes: `Auto-enrolled via ${req.auto_enrollment_token.token_name} on ${new Date().toISOString()}`,
updated_at: new Date(),
},
});
results.success.push({
id: host.id,
friendly_name: host.friendly_name,
api_id: api_id,
api_key: api_key,
});
} catch (error) {
results.failed.push({
friendly_name: host_data.friendly_name,
error: error.message,
});
}
}
// Update token usage stats
if (results.success.length > 0) {
await prisma.auto_enrollment_tokens.update({
where: { id: req.auto_enrollment_token.id },
data: {
hosts_created_today: { increment: results.success.length },
last_used_at: new Date(),
updated_at: new Date(),
},
});
}
res.status(201).json({
message: `Bulk enrollment completed: ${results.success.length} succeeded, ${results.failed.length} failed, ${results.skipped.length} skipped`,
results,
});
} catch (error) {
console.error("Bulk auto-enrollment error:", error);
res.status(500).json({ error: "Failed to bulk enroll hosts" });
}
},
);
module.exports = router;

View File

@@ -185,6 +185,7 @@ router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => {
// Show all hosts regardless of status
select: {
id: true,
machine_id: true,
friendly_name: true,
hostname: true,
ip: true,

View File

@@ -172,15 +172,6 @@ router.post(
// Generate unique API credentials for this host
const { apiId, apiKey } = generateApiCredentials();
// Check if host already exists
const existingHost = await prisma.hosts.findUnique({
where: { friendly_name: friendly_name },
});
if (existingHost) {
return res.status(409).json({ error: "Host already exists" });
}
// If hostGroupId is provided, verify the group exists
if (hostGroupId) {
const hostGroup = await prisma.host_groups.findUnique({
@@ -196,6 +187,7 @@ router.post(
const host = await prisma.hosts.create({
data: {
id: uuidv4(),
machine_id: `pending-${uuidv4()}`, // Temporary placeholder until agent connects with real machine_id
friendly_name: friendly_name,
os_type: "unknown", // Will be updated when agent connects
os_version: "unknown", // Will be updated when agent connects
@@ -321,6 +313,10 @@ router.post(
.optional()
.isArray()
.withMessage("Load average must be an array"),
body("machineId")
.optional()
.isString()
.withMessage("Machine ID must be a string"),
],
async (req, res) => {
try {
@@ -338,6 +334,11 @@ router.post(
updated_at: new Date(),
};
// Update machine_id if provided and current one is a placeholder
if (req.body.machineId && host.machine_id.startsWith("pending-")) {
updateData.machine_id = req.body.machineId;
}
// Basic system info
if (req.body.osType) updateData.os_type = req.body.osType;
if (req.body.osVersion) updateData.os_version = req.body.osVersion;
@@ -1126,12 +1127,16 @@ router.get("/install", async (req, res) => {
}
} catch (_) {}
// Inject the API credentials, server URL, and curl flags into the script
// Check for --force parameter
const forceInstall = req.query.force === "true" || req.query.force === "1";
// Inject the API credentials, server URL, curl flags, and force flag into the script
const envVars = `#!/bin/bash
export PATCHMON_URL="${serverUrl}"
export API_ID="${host.api_id}"
export API_KEY="${host.api_key}"
export CURL_FLAGS="${curlFlags}"
export FORCE_INSTALL="${forceInstall ? "true" : "false"}"
`;
@@ -1151,6 +1156,48 @@ export CURL_FLAGS="${curlFlags}"
}
});
// Check if machine_id already exists (requires auth)
router.post("/check-machine-id", validateApiCredentials, async (req, res) => {
try {
const { machine_id } = req.body;
if (!machine_id) {
return res.status(400).json({
error: "machine_id is required",
});
}
// Check if a host with this machine_id exists
const existing_host = await prisma.hosts.findUnique({
where: { machine_id },
select: {
id: true,
friendly_name: true,
machine_id: true,
api_id: true,
status: true,
created_at: true,
},
});
if (existing_host) {
return res.status(200).json({
exists: true,
host: existing_host,
message: "This machine is already enrolled",
});
}
return res.status(200).json({
exists: false,
message: "Machine not yet enrolled",
});
} catch (error) {
console.error("Error checking machine_id:", error);
res.status(500).json({ error: "Failed to check machine_id" });
}
});
// Serve the removal script (public endpoint - no authentication required)
router.get("/remove", async (_req, res) => {
try {

View File

@@ -67,7 +67,9 @@ router.get("/", async (req, res) => {
latest_version: true,
created_at: true,
_count: {
host_packages: true,
select: {
host_packages: true,
},
},
},
skip,
@@ -82,7 +84,7 @@ router.get("/", async (req, res) => {
// Get additional stats for each package
const packagesWithStats = await Promise.all(
packages.map(async (pkg) => {
const [updatesCount, securityCount, affectedHosts] = await Promise.all([
const [updatesCount, securityCount, packageHosts] = await Promise.all([
prisma.host_packages.count({
where: {
package_id: pkg.id,
@@ -117,17 +119,18 @@ router.get("/", async (req, res) => {
return {
...pkg,
affectedHostsCount: pkg._count.hostPackages,
affectedHosts: affectedHosts.map((hp) => ({
hostId: hp.host.id,
friendlyName: hp.host.friendly_name,
osType: hp.host.os_type,
packageHostsCount: pkg._count.host_packages,
packageHosts: packageHosts.map((hp) => ({
hostId: hp.hosts.id,
friendlyName: hp.hosts.friendly_name,
osType: hp.hosts.os_type,
currentVersion: hp.current_version,
availableVersion: hp.available_version,
needsUpdate: hp.needs_update,
isSecurityUpdate: hp.is_security_update,
})),
stats: {
totalInstalls: pkg._count.hostPackages,
totalInstalls: pkg._count.host_packages,
updatesNeeded: updatesCount,
securityUpdates: securityCount,
},
@@ -160,19 +163,19 @@ router.get("/:packageId", async (req, res) => {
include: {
host_packages: {
include: {
host: {
hosts: {
select: {
id: true,
hostname: true,
ip: true,
osType: true,
osVersion: true,
lastUpdate: true,
os_type: true,
os_version: true,
last_update: true,
},
},
},
orderBy: {
needsUpdate: "desc",
needs_update: "desc",
},
},
},
@@ -185,25 +188,25 @@ router.get("/:packageId", async (req, res) => {
// Calculate statistics
const stats = {
totalInstalls: packageData.host_packages.length,
updatesNeeded: packageData.host_packages.filter((hp) => hp.needsUpdate)
updatesNeeded: packageData.host_packages.filter((hp) => hp.needs_update)
.length,
securityUpdates: packageData.host_packages.filter(
(hp) => hp.needsUpdate && hp.isSecurityUpdate,
(hp) => hp.needs_update && hp.is_security_update,
).length,
upToDate: packageData.host_packages.filter((hp) => !hp.needsUpdate)
upToDate: packageData.host_packages.filter((hp) => !hp.needs_update)
.length,
};
// Group by version
const versionDistribution = packageData.host_packages.reduce((acc, hp) => {
const version = hp.currentVersion;
const version = hp.current_version;
acc[version] = (acc[version] || 0) + 1;
return acc;
}, {});
// Group by OS type
const osDistribution = packageData.host_packages.reduce((acc, hp) => {
const osType = hp.host.osType;
const osType = hp.hosts.os_type;
acc[osType] = (acc[osType] || 0) + 1;
return acc;
}, {});
@@ -230,4 +233,109 @@ router.get("/:packageId", async (req, res) => {
}
});
// Get hosts where a package is installed
router.get("/:packageId/hosts", async (req, res) => {
try {
const { packageId } = req.params;
const {
page = 1,
limit = 25,
search = "",
sortBy = "friendly_name",
sortOrder = "asc",
} = req.query;
const offset = (parseInt(page, 10) - 1) * parseInt(limit, 10);
// Build search conditions
const searchConditions = search
? {
OR: [
{
hosts: {
friendly_name: { contains: search, mode: "insensitive" },
},
},
{ hosts: { hostname: { contains: search, mode: "insensitive" } } },
{ current_version: { contains: search, mode: "insensitive" } },
{ available_version: { contains: search, mode: "insensitive" } },
],
}
: {};
// Build sort conditions
const orderBy = {};
if (
sortBy === "friendly_name" ||
sortBy === "hostname" ||
sortBy === "os_type"
) {
orderBy.hosts = { [sortBy]: sortOrder };
} else if (sortBy === "needs_update") {
orderBy[sortBy] = sortOrder;
} else {
orderBy[sortBy] = sortOrder;
}
// Get total count
const totalCount = await prisma.host_packages.count({
where: {
package_id: packageId,
...searchConditions,
},
});
// Get paginated results
const hostPackages = await prisma.host_packages.findMany({
where: {
package_id: packageId,
...searchConditions,
},
include: {
hosts: {
select: {
id: true,
friendly_name: true,
hostname: true,
os_type: true,
os_version: true,
last_update: true,
},
},
},
orderBy,
skip: offset,
take: parseInt(limit, 10),
});
// Transform the data for the frontend
const hosts = hostPackages.map((hp) => ({
hostId: hp.hosts.id,
friendlyName: hp.hosts.friendly_name,
hostname: hp.hosts.hostname,
osType: hp.hosts.os_type,
osVersion: hp.hosts.os_version,
lastUpdate: hp.hosts.last_update,
currentVersion: hp.current_version,
availableVersion: hp.available_version,
needsUpdate: hp.needs_update,
isSecurityUpdate: hp.is_security_update,
lastChecked: hp.last_checked,
}));
res.json({
hosts,
pagination: {
page: parseInt(page, 10),
limit: parseInt(limit, 10),
total: totalCount,
pages: Math.ceil(totalCount / parseInt(limit, 10)),
},
});
} catch (error) {
console.error("Error fetching package hosts:", error);
res.status(500).json({ error: "Failed to fetch package hosts" });
}
});
module.exports = router;

View File

@@ -289,6 +289,77 @@ router.get(
},
);
// Delete a specific repository (admin only)
router.delete(
"/:repositoryId",
authenticateToken,
requireManageHosts,
async (req, res) => {
try {
const { repositoryId } = req.params;
// Check if repository exists first
const existingRepository = await prisma.repositories.findUnique({
where: { id: repositoryId },
select: {
id: true,
name: true,
url: true,
_count: {
select: {
host_repositories: true,
},
},
},
});
if (!existingRepository) {
return res.status(404).json({
error: "Repository not found",
details: "The repository may have been deleted or does not exist",
});
}
// Delete repository and all related data (cascade will handle host_repositories)
await prisma.repositories.delete({
where: { id: repositoryId },
});
res.json({
message: "Repository deleted successfully",
deletedRepository: {
id: existingRepository.id,
name: existingRepository.name,
url: existingRepository.url,
hostCount: existingRepository._count.host_repositories,
},
});
} catch (error) {
console.error("Repository deletion error:", error);
// Handle specific Prisma errors
if (error.code === "P2025") {
return res.status(404).json({
error: "Repository not found",
details: "The repository may have been deleted or does not exist",
});
}
if (error.code === "P2003") {
return res.status(400).json({
error: "Cannot delete repository due to foreign key constraints",
details: "The repository has related data that prevents deletion",
});
}
res.status(500).json({
error: "Failed to delete repository",
details: error.message || "An unexpected error occurred",
});
}
},
);
// Cleanup orphaned repositories (admin only)
router.delete(
"/cleanup/orphaned",

View File

@@ -0,0 +1,249 @@
const express = require("express");
const router = express.Router();
const { createPrismaClient } = require("../config/database");
const { authenticateToken } = require("../middleware/auth");
const prisma = createPrismaClient();
/**
* Global search endpoint
* Searches across hosts, packages, repositories, and users
* Returns categorized results
*/
router.get("/", authenticateToken, async (req, res) => {
try {
const { q } = req.query;
if (!q || q.trim().length === 0) {
return res.json({
hosts: [],
packages: [],
repositories: [],
users: [],
});
}
const searchTerm = q.trim();
// Prepare results object
const results = {
hosts: [],
packages: [],
repositories: [],
users: [],
};
// Get user permissions from database
let userPermissions = null;
try {
userPermissions = await prisma.role_permissions.findUnique({
where: { role: req.user.role },
});
// If no specific permissions found, default to admin permissions
if (!userPermissions) {
console.warn(
`No permissions found for role: ${req.user.role}, defaulting to admin access`,
);
userPermissions = {
can_view_hosts: true,
can_view_packages: true,
can_view_users: true,
};
}
} catch (permError) {
console.error("Error fetching permissions:", permError);
// Default to restrictive permissions on error
userPermissions = {
can_view_hosts: false,
can_view_packages: false,
can_view_users: false,
};
}
// Search hosts if user has permission
if (userPermissions.can_view_hosts) {
try {
const hosts = await prisma.hosts.findMany({
where: {
OR: [
{ hostname: { contains: searchTerm, mode: "insensitive" } },
{ friendly_name: { contains: searchTerm, mode: "insensitive" } },
{ ip: { contains: searchTerm, mode: "insensitive" } },
{ machine_id: { contains: searchTerm, mode: "insensitive" } },
],
},
select: {
id: true,
machine_id: true,
hostname: true,
friendly_name: true,
ip: true,
os_type: true,
os_version: true,
status: true,
last_update: true,
},
take: 10, // Limit results
orderBy: {
last_update: "desc",
},
});
results.hosts = hosts.map((host) => ({
id: host.id,
hostname: host.hostname,
friendly_name: host.friendly_name,
ip: host.ip,
os_type: host.os_type,
os_version: host.os_version,
status: host.status,
last_update: host.last_update,
type: "host",
}));
} catch (error) {
console.error("Error searching hosts:", error);
}
}
// Search packages if user has permission
if (userPermissions.can_view_packages) {
try {
const packages = await prisma.packages.findMany({
where: {
name: { contains: searchTerm, mode: "insensitive" },
},
select: {
id: true,
name: true,
description: true,
category: true,
latest_version: true,
_count: {
select: {
host_packages: true,
},
},
},
take: 10,
orderBy: {
name: "asc",
},
});
results.packages = packages.map((pkg) => ({
id: pkg.id,
name: pkg.name,
description: pkg.description,
category: pkg.category,
latest_version: pkg.latest_version,
host_count: pkg._count.host_packages,
type: "package",
}));
} catch (error) {
console.error("Error searching packages:", error);
}
}
// Search repositories if user has permission (usually same as hosts)
if (userPermissions.can_view_hosts) {
try {
const repositories = await prisma.repositories.findMany({
where: {
OR: [
{ name: { contains: searchTerm, mode: "insensitive" } },
{ url: { contains: searchTerm, mode: "insensitive" } },
{ description: { contains: searchTerm, mode: "insensitive" } },
],
},
select: {
id: true,
name: true,
url: true,
distribution: true,
repo_type: true,
is_active: true,
description: true,
_count: {
select: {
host_repositories: true,
},
},
},
take: 10,
orderBy: {
name: "asc",
},
});
results.repositories = repositories.map((repo) => ({
id: repo.id,
name: repo.name,
url: repo.url,
distribution: repo.distribution,
repo_type: repo.repo_type,
is_active: repo.is_active,
description: repo.description,
host_count: repo._count.host_repositories,
type: "repository",
}));
} catch (error) {
console.error("Error searching repositories:", error);
}
}
// Search users if user has permission
if (userPermissions.can_view_users) {
try {
const users = await prisma.users.findMany({
where: {
OR: [
{ username: { contains: searchTerm, mode: "insensitive" } },
{ email: { contains: searchTerm, mode: "insensitive" } },
{ first_name: { contains: searchTerm, mode: "insensitive" } },
{ last_name: { contains: searchTerm, mode: "insensitive" } },
],
},
select: {
id: true,
username: true,
email: true,
first_name: true,
last_name: true,
role: true,
is_active: true,
last_login: true,
},
take: 10,
orderBy: {
username: "asc",
},
});
results.users = users.map((user) => ({
id: user.id,
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
is_active: user.is_active,
last_login: user.last_login,
type: "user",
}));
} catch (error) {
console.error("Error searching users:", error);
}
}
res.json(results);
} catch (error) {
console.error("Global search error:", error);
res.status(500).json({
error: "Failed to perform search",
message: error.message,
});
}
});
module.exports = router;

View File

@@ -215,6 +215,18 @@ router.put(
}
return true;
}),
body("logoDark")
.optional()
.isLength({ min: 1 })
.withMessage("Logo dark path must be a non-empty string"),
body("logoLight")
.optional()
.isLength({ min: 1 })
.withMessage("Logo light path must be a non-empty string"),
body("favicon")
.optional()
.isLength({ min: 1 })
.withMessage("Favicon path must be a non-empty string"),
],
async (req, res) => {
try {
@@ -236,6 +248,9 @@ router.put(
githubRepoUrl,
repositoryType,
sshKeyPath,
logoDark,
logoLight,
favicon,
} = req.body;
// Get current settings to check for update interval changes
@@ -264,6 +279,9 @@ router.put(
if (repositoryType !== undefined)
updateData.repository_type = repositoryType;
if (sshKeyPath !== undefined) updateData.ssh_key_path = sshKeyPath;
if (logoDark !== undefined) updateData.logo_dark = logoDark;
if (logoLight !== undefined) updateData.logo_light = logoLight;
if (favicon !== undefined) updateData.favicon = favicon;
const updatedSettings = await updateSettings(
currentSettings.id,
@@ -351,4 +369,175 @@ router.get("/auto-update", async (_req, res) => {
}
});
// Upload logo files
router.post(
"/logos/upload",
authenticateToken,
requireManageSettings,
async (req, res) => {
try {
const { logoType, fileContent, fileName } = req.body;
if (!logoType || !fileContent) {
return res.status(400).json({
error: "Logo type and file content are required",
});
}
if (!["dark", "light", "favicon"].includes(logoType)) {
return res.status(400).json({
error: "Logo type must be 'dark', 'light', or 'favicon'",
});
}
// Validate file content (basic checks)
if (typeof fileContent !== "string") {
return res.status(400).json({
error: "File content must be a base64 string",
});
}
const fs = require("node:fs").promises;
const path = require("node:path");
const _crypto = require("node:crypto");
// Create assets directory if it doesn't exist
// In development: save to public/assets (served by Vite)
// In production: save to dist/assets (served by built app)
const isDevelopment = process.env.NODE_ENV !== "production";
const assetsDir = isDevelopment
? path.join(__dirname, "../../../frontend/public/assets")
: path.join(__dirname, "../../../frontend/dist/assets");
await fs.mkdir(assetsDir, { recursive: true });
// Determine file extension and path
let fileExtension;
let fileName_final;
if (logoType === "favicon") {
fileExtension = ".svg";
fileName_final = fileName || "logo_square.svg";
} else {
// Determine extension from file content or use default
if (fileContent.startsWith("data:image/png")) {
fileExtension = ".png";
} else if (fileContent.startsWith("data:image/svg")) {
fileExtension = ".svg";
} else if (
fileContent.startsWith("data:image/jpeg") ||
fileContent.startsWith("data:image/jpg")
) {
fileExtension = ".jpg";
} else {
fileExtension = ".png"; // Default to PNG
}
fileName_final = fileName || `logo_${logoType}${fileExtension}`;
}
const filePath = path.join(assetsDir, fileName_final);
// Handle base64 data URLs
let fileBuffer;
if (fileContent.startsWith("data:")) {
const base64Data = fileContent.split(",")[1];
fileBuffer = Buffer.from(base64Data, "base64");
} else {
// Assume it's already base64
fileBuffer = Buffer.from(fileContent, "base64");
}
// Create backup of existing file
try {
const backupPath = `${filePath}.backup.${Date.now()}`;
await fs.copyFile(filePath, backupPath);
console.log(`Created backup: ${backupPath}`);
} catch (error) {
// Ignore if original doesn't exist
if (error.code !== "ENOENT") {
console.warn("Failed to create backup:", error.message);
}
}
// Write new logo file
await fs.writeFile(filePath, fileBuffer);
// Update settings with new logo path
const settings = await getSettings();
const logoPath = `/assets/${fileName_final}`;
const updateData = {};
if (logoType === "dark") {
updateData.logo_dark = logoPath;
} else if (logoType === "light") {
updateData.logo_light = logoPath;
} else if (logoType === "favicon") {
updateData.favicon = logoPath;
}
await updateSettings(settings.id, updateData);
// Get file stats
const stats = await fs.stat(filePath);
res.json({
message: `${logoType} logo uploaded successfully`,
fileName: fileName_final,
path: logoPath,
size: stats.size,
sizeFormatted: `${(stats.size / 1024).toFixed(1)} KB`,
});
} catch (error) {
console.error("Upload logo error:", error);
res.status(500).json({ error: "Failed to upload logo" });
}
},
);
// Reset logo to default
router.post(
"/logos/reset",
authenticateToken,
requireManageSettings,
async (req, res) => {
try {
const { logoType } = req.body;
if (!logoType) {
return res.status(400).json({
error: "Logo type is required",
});
}
if (!["dark", "light", "favicon"].includes(logoType)) {
return res.status(400).json({
error: "Logo type must be 'dark', 'light', or 'favicon'",
});
}
// Get current settings
const settings = await getSettings();
// Clear the custom logo path to revert to default
const updateData = {};
if (logoType === "dark") {
updateData.logo_dark = null;
} else if (logoType === "light") {
updateData.logo_light = null;
} else if (logoType === "favicon") {
updateData.favicon = null;
}
await updateSettings(settings.id, updateData);
res.json({
message: `${logoType} logo reset to default successfully`,
logoType,
});
} catch (error) {
console.error("Reset logo error:", error);
res.status(500).json({ error: "Failed to reset logo" });
}
},
);
module.exports = router;

View File

@@ -2,36 +2,211 @@ const express = require("express");
const { authenticateToken } = require("../middleware/auth");
const { requireManageSettings } = require("../middleware/permissions");
const { PrismaClient } = require("@prisma/client");
const { exec } = require("node:child_process");
const { promisify } = require("node:util");
const prisma = new PrismaClient();
const execAsync = promisify(exec);
// Default GitHub repository URL
const DEFAULT_GITHUB_REPO = "https://github.com/patchMon/patchmon";
const router = express.Router();
// Helper function to get current version from package.json
function getCurrentVersion() {
try {
const packageJson = require("../../package.json");
return packageJson?.version || "1.2.7";
} catch (packageError) {
console.warn(
"Could not read version from package.json, using fallback:",
packageError.message,
);
return "1.2.7";
}
}
// Helper function to parse GitHub repository URL
function parseGitHubRepo(repoUrl) {
let owner, repo;
if (repoUrl.includes("git@github.com:")) {
const match = repoUrl.match(/git@github\.com:([^/]+)\/([^/]+)\.git/);
if (match) {
[, owner, repo] = match;
}
} else if (repoUrl.includes("github.com/")) {
const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/);
if (match) {
[, owner, repo] = match;
}
}
return { owner, repo };
}
// Helper function to get latest release from GitHub API
async function getLatestRelease(owner, repo) {
try {
const currentVersion = getCurrentVersion();
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
const response = await fetch(apiUrl, {
method: "GET",
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": `PatchMon-Server/${currentVersion}`,
},
});
if (!response.ok) {
const errorText = await response.text();
if (
errorText.includes("rate limit") ||
errorText.includes("API rate limit")
) {
throw new Error("GitHub API rate limit exceeded");
}
throw new Error(
`GitHub API error: ${response.status} ${response.statusText}`,
);
}
const releaseData = await response.json();
return {
tagName: releaseData.tag_name,
version: releaseData.tag_name.replace("v", ""),
publishedAt: releaseData.published_at,
htmlUrl: releaseData.html_url,
};
} catch (error) {
console.error("Error fetching latest release:", error.message);
throw error; // Re-throw to be caught by the calling function
}
}
// Helper function to get latest commit from main branch
async function getLatestCommit(owner, repo) {
try {
const currentVersion = getCurrentVersion();
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/commits/main`;
const response = await fetch(apiUrl, {
method: "GET",
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": `PatchMon-Server/${currentVersion}`,
},
});
if (!response.ok) {
const errorText = await response.text();
if (
errorText.includes("rate limit") ||
errorText.includes("API rate limit")
) {
throw new Error("GitHub API rate limit exceeded");
}
throw new Error(
`GitHub API error: ${response.status} ${response.statusText}`,
);
}
const commitData = await response.json();
return {
sha: commitData.sha,
message: commitData.commit.message,
author: commitData.commit.author.name,
date: commitData.commit.author.date,
htmlUrl: commitData.html_url,
};
} catch (error) {
console.error("Error fetching latest commit:", error.message);
throw error; // Re-throw to be caught by the calling function
}
}
// Helper function to get commit count difference
async function getCommitDifference(owner, repo, currentVersion) {
try {
const currentVersionTag = `v${currentVersion}`;
// Compare main branch with the released version tag
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/compare/${currentVersionTag}...main`;
const response = await fetch(apiUrl, {
method: "GET",
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": `PatchMon-Server/${getCurrentVersion()}`,
},
});
if (!response.ok) {
const errorText = await response.text();
if (
errorText.includes("rate limit") ||
errorText.includes("API rate limit")
) {
throw new Error("GitHub API rate limit exceeded");
}
throw new Error(
`GitHub API error: ${response.status} ${response.statusText}`,
);
}
const compareData = await response.json();
return {
commitsBehind: compareData.behind_by || 0, // How many commits main is behind release
commitsAhead: compareData.ahead_by || 0, // How many commits main is ahead of release
totalCommits: compareData.total_commits || 0,
branchInfo: "main branch vs release",
};
} catch (error) {
console.error("Error fetching commit difference:", error.message);
throw error;
}
}
// Helper function to compare version strings (semantic versioning)
function compareVersions(version1, version2) {
const v1parts = version1.split(".").map(Number);
const v2parts = version2.split(".").map(Number);
const maxLength = Math.max(v1parts.length, v2parts.length);
for (let i = 0; i < maxLength; i++) {
const v1part = v1parts[i] || 0;
const v2part = v2parts[i] || 0;
if (v1part > v2part) return 1;
if (v1part < v2part) return -1;
}
return 0;
}
// Get current version info
router.get("/current", authenticateToken, async (_req, res) => {
try {
// Read version from package.json dynamically
let currentVersion = "1.2.7"; // fallback
const currentVersion = getCurrentVersion();
try {
const packageJson = require("../../package.json");
if (packageJson?.version) {
currentVersion = packageJson.version;
}
} catch (packageError) {
console.warn(
"Could not read version from package.json, using fallback:",
packageError.message,
);
}
// Get settings with cached update info (no GitHub API calls)
const settings = await prisma.settings.findFirst();
const githubRepoUrl = settings?.githubRepoUrl || DEFAULT_GITHUB_REPO;
const { owner, repo } = parseGitHubRepo(githubRepoUrl);
// Return current version and cached update information
// The backend scheduler updates this data periodically
res.json({
version: currentVersion,
latest_version: settings?.latest_version || null,
is_update_available: settings?.is_update_available || false,
last_update_check: settings?.last_update_check || null,
buildDate: new Date().toISOString(),
environment: process.env.NODE_ENV || "development",
github: {
repository: githubRepoUrl,
owner: owner,
repo: repo,
},
});
} catch (error) {
console.error("Error getting current version:", error);
@@ -44,119 +219,11 @@ router.post(
"/test-ssh-key",
authenticateToken,
requireManageSettings,
async (req, res) => {
try {
const { sshKeyPath, githubRepoUrl } = req.body;
if (!sshKeyPath || !githubRepoUrl) {
return res.status(400).json({
error: "SSH key path and GitHub repo URL are required",
});
}
// Parse repository info
let owner, repo;
if (githubRepoUrl.includes("git@github.com:")) {
const match = githubRepoUrl.match(
/git@github\.com:([^/]+)\/([^/]+)\.git/,
);
if (match) {
[, owner, repo] = match;
}
} else if (githubRepoUrl.includes("github.com/")) {
const match = githubRepoUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
if (match) {
[, owner, repo] = match;
}
}
if (!owner || !repo) {
return res.status(400).json({
error: "Invalid GitHub repository URL format",
});
}
// Check if SSH key file exists and is readable
try {
require("node:fs").accessSync(sshKeyPath);
} catch {
return res.status(400).json({
error: "SSH key file not found or not accessible",
details: `Cannot access: ${sshKeyPath}`,
suggestion:
"Check the file path and ensure the application has read permissions",
});
}
// Test SSH connection to GitHub
const sshRepoUrl = `git@github.com:${owner}/${repo}.git`;
const env = {
...process.env,
GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -o ConnectTimeout=10`,
};
try {
// Test with a simple git command
const { stdout } = await execAsync(
`git ls-remote --heads ${sshRepoUrl} | head -n 1`,
{
timeout: 15000,
env: env,
},
);
if (stdout.trim()) {
return res.json({
success: true,
message: "SSH key is working correctly",
details: {
sshKeyPath,
repository: `${owner}/${repo}`,
testResult: "Successfully connected to GitHub",
},
});
} else {
return res.status(400).json({
error: "SSH connection succeeded but no data returned",
suggestion: "Check repository access permissions",
});
}
} catch (sshError) {
console.error("SSH test error:", sshError.message);
if (sshError.message.includes("Permission denied")) {
return res.status(403).json({
error: "SSH key permission denied",
details: "The SSH key exists but GitHub rejected the connection",
suggestion:
"Verify the SSH key is added to the repository as a deploy key with read access",
});
} else if (sshError.message.includes("Host key verification failed")) {
return res.status(403).json({
error: "Host key verification failed",
suggestion:
"This is normal for first-time connections. The key will be added to known_hosts automatically.",
});
} else if (sshError.message.includes("Connection timed out")) {
return res.status(408).json({
error: "Connection timed out",
suggestion: "Check your internet connection and GitHub status",
});
} else {
return res.status(500).json({
error: "SSH connection failed",
details: sshError.message,
suggestion: "Check the SSH key format and repository URL",
});
}
}
} catch (error) {
console.error("SSH key test error:", error);
res.status(500).json({
error: "Failed to test SSH key",
details: error.message,
});
}
async (_req, res) => {
res.status(410).json({
error:
"SSH key testing has been removed. Using default public repository.",
});
},
);
@@ -174,24 +241,90 @@ router.get(
return res.status(400).json({ error: "Settings not found" });
}
const currentVersion = "1.2.7";
const latestVersion = settings.latest_version || currentVersion;
const isUpdateAvailable = settings.update_available || false;
const lastUpdateCheck = settings.last_update_check || null;
const currentVersion = getCurrentVersion();
const githubRepoUrl = settings.githubRepoUrl || DEFAULT_GITHUB_REPO;
const { owner, repo } = parseGitHubRepo(githubRepoUrl);
let latestRelease = null;
let latestCommit = null;
let commitDifference = null;
// Fetch fresh GitHub data if we have valid owner/repo
if (owner && repo) {
try {
const [releaseData, commitData, differenceData] = await Promise.all([
getLatestRelease(owner, repo),
getLatestCommit(owner, repo),
getCommitDifference(owner, repo, currentVersion),
]);
latestRelease = releaseData;
latestCommit = commitData;
commitDifference = differenceData;
} catch (githubError) {
console.warn(
"Failed to fetch fresh GitHub data:",
githubError.message,
);
// Provide fallback data when GitHub API is rate-limited
if (
githubError.message.includes("rate limit") ||
githubError.message.includes("API rate limit")
) {
console.log("GitHub API rate limited, providing fallback data");
latestRelease = {
tagName: "v1.2.7",
version: "1.2.7",
publishedAt: "2025-10-02T17:12:53Z",
htmlUrl:
"https://github.com/PatchMon/PatchMon/releases/tag/v1.2.7",
};
latestCommit = {
sha: "cc89df161b8ea5d48ff95b0eb405fe69042052cd",
message: "Update README.md\n\nAdded Documentation Links",
author: "9 Technology Group LTD",
date: "2025-10-04T18:38:09Z",
htmlUrl:
"https://github.com/PatchMon/PatchMon/commit/cc89df161b8ea5d48ff95b0eb405fe69042052cd",
};
commitDifference = {
commitsBehind: 0,
commitsAhead: 3, // Main branch is ahead of release
totalCommits: 3,
branchInfo: "main branch vs release",
};
} else {
// Fall back to cached data for other errors
latestRelease = settings.latest_version
? {
version: settings.latest_version,
tagName: `v${settings.latest_version}`,
}
: null;
}
}
}
const latestVersion =
latestRelease?.version || settings.latest_version || currentVersion;
const isUpdateAvailable = latestRelease
? compareVersions(latestVersion, currentVersion) > 0
: settings.update_available || false;
res.json({
currentVersion,
latestVersion,
isUpdateAvailable,
lastUpdateCheck,
lastUpdateCheck: settings.last_update_check || null,
repositoryType: settings.repository_type || "public",
latestRelease: {
tagName: latestVersion ? `v${latestVersion}` : null,
version: latestVersion,
repository: settings.github_repo_url
? settings.github_repo_url.split("/").slice(-2).join("/")
: null,
accessMethod: settings.repository_type === "private" ? "ssh" : "api",
github: {
repository: githubRepoUrl,
owner: owner,
repo: repo,
latestRelease: latestRelease,
latestCommit: latestCommit,
commitDifference: commitDifference,
},
});
} catch (error) {

View File

@@ -60,6 +60,8 @@ const {
const repositoryRoutes = require("./routes/repositoryRoutes");
const versionRoutes = require("./routes/versionRoutes");
const tfaRoutes = require("./routes/tfaRoutes");
const searchRoutes = require("./routes/searchRoutes");
const autoEnrollmentRoutes = require("./routes/autoEnrollmentRoutes");
const updateScheduler = require("./services/updateScheduler");
const { initSettings } = require("./services/settingsService");
const { cleanup_expired_sessions } = require("./utils/session_manager");
@@ -414,6 +416,12 @@ app.use(`/api/${apiVersion}/dashboard-preferences`, dashboardPreferencesRoutes);
app.use(`/api/${apiVersion}/repositories`, repositoryRoutes);
app.use(`/api/${apiVersion}/version`, versionRoutes);
app.use(`/api/${apiVersion}/tfa`, tfaRoutes);
app.use(`/api/${apiVersion}/search`, searchRoutes);
app.use(
`/api/${apiVersion}/auto-enrollment`,
authLimiter,
autoEnrollmentRoutes,
);
// Error handling middleware
app.use((err, _req, res, _next) => {

View File

@@ -60,13 +60,8 @@ class UpdateScheduler {
// Get settings
const settings = await prisma.settings.findFirst();
if (!settings || !settings.githubRepoUrl) {
console.log("⚠️ No GitHub repository configured, skipping update check");
return;
}
// Extract owner and repo from GitHub URL
const repoUrl = settings.githubRepoUrl;
const DEFAULT_GITHUB_REPO = "https://github.com/patchMon/patchmon";
const repoUrl = settings?.githubRepoUrl || DEFAULT_GITHUB_REPO;
let owner, repo;
if (repoUrl.includes("git@github.com:")) {
@@ -128,9 +123,9 @@ class UpdateScheduler {
await prisma.settings.update({
where: { id: settings.id },
data: {
lastUpdateCheck: new Date(),
updateAvailable: isUpdateAvailable,
latestVersion: latestVersion,
last_update_check: new Date(),
update_available: isUpdateAvailable,
latest_version: latestVersion,
},
});
@@ -147,8 +142,8 @@ class UpdateScheduler {
await prisma.settings.update({
where: { id: settings.id },
data: {
lastUpdateCheck: new Date(),
updateAvailable: false,
last_update_check: new Date(),
update_available: false,
},
});
}
@@ -241,6 +236,16 @@ class UpdateScheduler {
});
if (!response.ok) {
const errorText = await response.text();
if (
errorText.includes("rate limit") ||
errorText.includes("API rate limit")
) {
console.log(
"⚠️ GitHub API rate limit exceeded, skipping update check",
);
return null; // Return null instead of throwing error
}
throw new Error(
`GitHub API error: ${response.status} ${response.statusText}`,
);

View File

@@ -1,5 +1,5 @@
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const crypto = require("node:crypto");
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
@@ -9,7 +9,10 @@ const prisma = new PrismaClient();
*/
// Configuration
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
if (!process.env.JWT_SECRET) {
throw new Error("JWT_SECRET environment variable is required");
}
const JWT_SECRET = process.env.JWT_SECRET;
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "1h";
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || "7d";
const INACTIVITY_TIMEOUT_MINUTES = parseInt(

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PatchMon - Linux Patch Monitoring Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">

View File

@@ -1,6 +1,7 @@
import { Route, Routes } from "react-router-dom";
import FirstTimeAdminSetup from "./components/FirstTimeAdminSetup";
import Layout from "./components/Layout";
import LogoProvider from "./components/LogoProvider";
import ProtectedRoute from "./components/ProtectedRoute";
import SettingsLayout from "./components/SettingsLayout";
import { isAuthPhase } from "./constants/authPhases";
@@ -290,6 +291,16 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/settings/branding"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/agent-version"
element={
@@ -329,7 +340,9 @@ function App() {
<ThemeProvider>
<AuthProvider>
<UpdateNotificationProvider>
<AppRoutes />
<LogoProvider>
<AppRoutes />
</LogoProvider>
</UpdateNotificationProvider>
</AuthProvider>
</ThemeProvider>

View File

@@ -0,0 +1,16 @@
const DiscordIcon = ({ className = "h-5 w-5" }) => {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
className={className}
xmlns="http://www.w3.org/2000/svg"
aria-label="Discord"
>
<title>Discord</title>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z" />
</svg>
);
};
export default DiscordIcon;

View File

@@ -0,0 +1,428 @@
import { GitBranch, Package, Search, Server, User, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { searchAPI } from "../utils/api";
const GlobalSearch = () => {
const [query, setQuery] = useState("");
const [results, setResults] = useState(null);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const searchRef = useRef(null);
const inputRef = useRef(null);
const navigate = useNavigate();
// Debounce search
const debounceTimerRef = useRef(null);
const performSearch = useCallback(async (searchQuery) => {
if (!searchQuery || searchQuery.trim().length === 0) {
setResults(null);
setIsOpen(false);
return;
}
setIsLoading(true);
try {
const response = await searchAPI.global(searchQuery);
setResults(response.data);
setIsOpen(true);
setSelectedIndex(-1);
} catch (error) {
console.error("Search error:", error);
setResults(null);
} finally {
setIsLoading(false);
}
}, []);
const handleInputChange = (e) => {
const value = e.target.value;
setQuery(value);
// Clear previous timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Set new timer
debounceTimerRef.current = setTimeout(() => {
performSearch(value);
}, 300);
};
const handleClear = () => {
// Clear debounce timer to prevent any pending searches
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
setQuery("");
setResults(null);
setIsOpen(false);
setSelectedIndex(-1);
inputRef.current?.focus();
};
const handleResultClick = (result) => {
// Navigate based on result type
switch (result.type) {
case "host":
navigate(`/hosts/${result.id}`);
break;
case "package":
navigate(`/packages/${result.id}`);
break;
case "repository":
navigate(`/repositories/${result.id}`);
break;
case "user":
// Users don't have detail pages, so navigate to settings
navigate("/settings/users");
break;
default:
break;
}
// Close dropdown and clear
handleClear();
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (searchRef.current && !searchRef.current.contains(event.target)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
// Keyboard navigation
const flattenedResults = [];
if (results) {
if (results.hosts?.length > 0) {
flattenedResults.push({ type: "header", label: "Hosts" });
flattenedResults.push(...results.hosts);
}
if (results.packages?.length > 0) {
flattenedResults.push({ type: "header", label: "Packages" });
flattenedResults.push(...results.packages);
}
if (results.repositories?.length > 0) {
flattenedResults.push({ type: "header", label: "Repositories" });
flattenedResults.push(...results.repositories);
}
if (results.users?.length > 0) {
flattenedResults.push({ type: "header", label: "Users" });
flattenedResults.push(...results.users);
}
}
const navigableResults = flattenedResults.filter((r) => r.type !== "header");
const handleKeyDown = (e) => {
if (!isOpen || !results) return;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex((prev) =>
prev < navigableResults.length - 1 ? prev + 1 : prev,
);
break;
case "ArrowUp":
e.preventDefault();
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1));
break;
case "Enter":
e.preventDefault();
if (selectedIndex >= 0 && navigableResults[selectedIndex]) {
handleResultClick(navigableResults[selectedIndex]);
}
break;
case "Escape":
e.preventDefault();
setIsOpen(false);
setSelectedIndex(-1);
break;
default:
break;
}
};
// Get icon for result type
const getResultIcon = (type) => {
switch (type) {
case "host":
return <Server className="h-4 w-4 text-blue-500" />;
case "package":
return <Package className="h-4 w-4 text-green-500" />;
case "repository":
return <GitBranch className="h-4 w-4 text-purple-500" />;
case "user":
return <User className="h-4 w-4 text-orange-500" />;
default:
return null;
}
};
// Get display text for result
const getResultDisplay = (result) => {
switch (result.type) {
case "host":
return {
primary: result.friendly_name || result.hostname,
secondary: result.ip || result.hostname,
};
case "package":
return {
primary: result.name,
secondary: result.description || result.category,
};
case "repository":
return {
primary: result.name,
secondary: result.distribution,
};
case "user":
return {
primary: result.username,
secondary: result.email,
};
default:
return { primary: "", secondary: "" };
}
};
const hasResults =
results &&
(results.hosts?.length > 0 ||
results.packages?.length > 0 ||
results.repositories?.length > 0 ||
results.users?.length > 0);
return (
<div ref={searchRef} className="relative w-full max-w-sm">
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Search className="h-5 w-5 text-secondary-400" />
</div>
<input
ref={inputRef}
type="text"
className="block w-full rounded-lg border border-secondary-200 bg-white py-2 pl-10 pr-10 text-sm text-secondary-900 placeholder-secondary-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-secondary-600 dark:bg-secondary-700 dark:text-white dark:placeholder-secondary-400"
placeholder="Search hosts, packages, repos, users..."
value={query}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={() => {
if (query && results) setIsOpen(true);
}}
/>
{query && (
<button
type="button"
onClick={handleClear}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-secondary-400 hover:text-secondary-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Dropdown Results */}
{isOpen && (
<div className="absolute z-50 mt-2 w-full rounded-lg border border-secondary-200 bg-white shadow-lg dark:border-secondary-600 dark:bg-secondary-800">
{isLoading ? (
<div className="px-4 py-2 text-center text-sm text-secondary-500">
Searching...
</div>
) : hasResults ? (
<div className="max-h-96 overflow-y-auto">
{/* Hosts */}
{results.hosts?.length > 0 && (
<div>
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
Hosts
</div>
{results.hosts.map((host, _idx) => {
const display = getResultDisplay(host);
const globalIdx = navigableResults.findIndex(
(r) => r.id === host.id && r.type === "host",
);
return (
<button
type="button"
key={host.id}
onClick={() => handleResultClick(host)}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
globalIdx === selectedIndex
? "bg-primary-50 dark:bg-primary-900/20"
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
}`}
>
{getResultIcon("host")}
<div className="flex-1 min-w-0 flex items-center gap-2">
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
{display.primary}
</span>
<span className="text-xs text-secondary-400"></span>
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
{display.secondary}
</span>
</div>
<div className="flex-shrink-0 text-xs text-secondary-400">
{host.os_type}
</div>
</button>
);
})}
</div>
)}
{/* Packages */}
{results.packages?.length > 0 && (
<div>
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
Packages
</div>
{results.packages.map((pkg, _idx) => {
const display = getResultDisplay(pkg);
const globalIdx = navigableResults.findIndex(
(r) => r.id === pkg.id && r.type === "package",
);
return (
<button
type="button"
key={pkg.id}
onClick={() => handleResultClick(pkg)}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
globalIdx === selectedIndex
? "bg-primary-50 dark:bg-primary-900/20"
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
}`}
>
{getResultIcon("package")}
<div className="flex-1 min-w-0 flex items-center gap-2">
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
{display.primary}
</span>
{display.secondary && (
<>
<span className="text-xs text-secondary-400">
</span>
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
{display.secondary}
</span>
</>
)}
</div>
<div className="flex-shrink-0 text-xs text-secondary-400">
{pkg.host_count} hosts
</div>
</button>
);
})}
</div>
)}
{/* Repositories */}
{results.repositories?.length > 0 && (
<div>
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
Repositories
</div>
{results.repositories.map((repo, _idx) => {
const display = getResultDisplay(repo);
const globalIdx = navigableResults.findIndex(
(r) => r.id === repo.id && r.type === "repository",
);
return (
<button
type="button"
key={repo.id}
onClick={() => handleResultClick(repo)}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
globalIdx === selectedIndex
? "bg-primary-50 dark:bg-primary-900/20"
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
}`}
>
{getResultIcon("repository")}
<div className="flex-1 min-w-0 flex items-center gap-2">
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
{display.primary}
</span>
<span className="text-xs text-secondary-400"></span>
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
{display.secondary}
</span>
</div>
<div className="flex-shrink-0 text-xs text-secondary-400">
{repo.host_count} hosts
</div>
</button>
);
})}
</div>
)}
{/* Users */}
{results.users?.length > 0 && (
<div>
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
Users
</div>
{results.users.map((user, _idx) => {
const display = getResultDisplay(user);
const globalIdx = navigableResults.findIndex(
(r) => r.id === user.id && r.type === "user",
);
return (
<button
type="button"
key={user.id}
onClick={() => handleResultClick(user)}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
globalIdx === selectedIndex
? "bg-primary-50 dark:bg-primary-900/20"
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
}`}
>
{getResultIcon("user")}
<div className="flex-1 min-w-0 flex items-center gap-2">
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
{display.primary}
</span>
<span className="text-xs text-secondary-400"></span>
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
{display.secondary}
</span>
</div>
<div className="flex-shrink-0 text-xs text-secondary-400">
{user.role}
</div>
</button>
);
})}
</div>
)}
</div>
) : query.trim() ? (
<div className="px-4 py-2 text-center text-sm text-secondary-500">
No results found for "{query}"
</div>
) : null}
</div>
)}
</div>
);
};
export default GlobalSearch;

View File

@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import {
Activity,
BarChart3,
BookOpen,
ChevronLeft,
ChevronRight,
Clock,
@@ -13,13 +14,12 @@ import {
LogOut,
Mail,
Menu,
MessageCircle,
Package,
Plus,
RefreshCw,
Route,
Server,
Settings,
Shield,
Star,
UserCircle,
X,
@@ -29,6 +29,9 @@ import { Link, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
import { dashboardAPI, versionAPI } from "../utils/api";
import DiscordIcon from "./DiscordIcon";
import GlobalSearch from "./GlobalSearch";
import Logo from "./Logo";
import UpgradeNotificationIcon from "./UpgradeNotificationIcon";
const Layout = ({ children }) => {
@@ -292,7 +295,7 @@ const Layout = ({ children }) => {
onClick={() => setSidebarOpen(false)}
aria-label="Close sidebar"
/>
<div className="relative flex w-full max-w-xs flex-col bg-white pb-4 pt-5 shadow-xl">
<div className="relative flex w-full max-w-[280px] flex-col bg-white pb-4 pt-5 shadow-xl">
<div className="absolute right-0 top-0 -mr-12 pt-2">
<button
type="button"
@@ -302,13 +305,10 @@ const Layout = ({ children }) => {
<X className="h-6 w-6 text-white" />
</button>
</div>
<div className="flex flex-shrink-0 items-center px-4">
<div className="flex items-center">
<Shield className="h-8 w-8 text-primary-600" />
<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">
PatchMon
</h1>
</div>
<div className="flex flex-shrink-0 items-center justify-center px-4">
<Link to="/" className="flex items-center">
<Logo className="h-10 w-auto" alt="PatchMon Logo" />
</Link>
</div>
<nav className="mt-8 flex-1 space-y-6 px-2">
{/* Show message for users with very limited permissions */}
@@ -344,7 +344,7 @@ const Layout = ({ children }) => {
// Section with items
return (
<div key={item.section}>
<h3 className="text-xs font-semibold text-secondary-500 uppercase tracking-wider mb-2 px-2">
<h3 className="text-xs font-semibold text-secondary-500 uppercase tracking-wider mb-2">
{item.section}
</h3>
<div className="space-y-1">
@@ -464,8 +464,8 @@ const Layout = ({ children }) => {
{/* Desktop sidebar */}
<div
className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 ${
sidebarCollapsed ? "lg:w-16" : "lg:w-64"
className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 relative ${
sidebarCollapsed ? "lg:w-16" : "lg:w-56"
} bg-white dark:bg-secondary-800`}
>
<div
@@ -474,38 +474,37 @@ const Layout = ({ children }) => {
}`}
>
<div
className={`flex h-16 shrink-0 items-center border-b border-secondary-200 ${
sidebarCollapsed ? "justify-center" : "justify-between"
className={`flex h-16 shrink-0 items-center border-b border-secondary-200 dark:border-secondary-600 ${
sidebarCollapsed ? "justify-center" : "justify-center"
}`}
>
{sidebarCollapsed ? (
<button
type="button"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
title="Expand sidebar"
>
<ChevronRight className="h-5 w-5 text-secondary-700 dark:text-white" />
</button>
<Link to="/" className="flex items-center">
<img
src="/assets/favicon.svg"
alt="PatchMon"
className="h-12 w-12 object-contain"
/>
</Link>
) : (
<>
<div className="flex items-center">
<Shield className="h-8 w-8 text-primary-600" />
<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">
PatchMon
</h1>
</div>
<button
type="button"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
title="Collapse sidebar"
>
<ChevronLeft className="h-5 w-5 text-secondary-700 dark:text-white" />
</button>
</>
<Link to="/" className="flex items-center">
<Logo className="h-10 w-auto" alt="PatchMon Logo" />
</Link>
)}
</div>
{/* Collapse/Expand button on border */}
<button
type="button"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="absolute top-5 -right-3 z-10 flex items-center justify-center w-6 h-6 rounded-full bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 shadow-md hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{sidebarCollapsed ? (
<ChevronRight className="h-4 w-4 text-secondary-700 dark:text-white" />
) : (
<ChevronLeft className="h-4 w-4 text-secondary-700 dark:text-white" />
)}
</button>
<nav className="flex flex-1 flex-col">
<ul className="flex flex-1 flex-col gap-y-6">
{/* Show message for users with very limited permissions */}
@@ -523,7 +522,10 @@ const Layout = ({ children }) => {
if (item.name) {
// Single item (Dashboard)
return (
<li key={item.name}>
<li
key={item.name}
className={sidebarCollapsed ? "" : "-mx-2"}
>
<Link
to={item.href}
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-semibold transition-all duration-200 ${
@@ -547,7 +549,7 @@ const Layout = ({ children }) => {
return (
<li key={item.section}>
{!sidebarCollapsed && (
<h3 className="text-xs font-semibold text-secondary-500 dark:text-secondary-300 uppercase tracking-wider mb-2 px-2">
<h3 className="text-xs font-semibold text-secondary-500 dark:text-secondary-300 uppercase tracking-wider mb-2">
{item.section}
</h3>
)}
@@ -849,7 +851,7 @@ const Layout = ({ children }) => {
{/* Main content */}
<div
className={`flex flex-col min-h-screen transition-all duration-300 ${
sidebarCollapsed ? "lg:pl-16" : "lg:pl-64"
sidebarCollapsed ? "lg:pl-16" : "lg:pl-56"
}`}
>
{/* Top bar */}
@@ -866,16 +868,22 @@ const Layout = ({ children }) => {
<div className="h-6 w-px bg-secondary-200 lg:hidden" />
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
<div className="relative flex flex-1 items-center">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
<div className="relative flex items-center">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100 whitespace-nowrap">
{getPageTitle()}
</h2>
</div>
<div className="flex items-center gap-x-4 lg:gap-x-6">
{/* Global Search Bar */}
<div className="hidden md:flex items-center max-w-sm">
<GlobalSearch />
</div>
<div className="flex flex-1 items-center gap-x-4 lg:gap-x-6 justify-end">
{/* External Links */}
<div className="flex items-center gap-2">
<a
href="https://github.com/9technologygroup/patchmon.net"
href="https://github.com/PatchMon/PatchMon"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 px-3 py-2 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm group relative"
@@ -888,6 +896,15 @@ const Layout = ({ children }) => {
</div>
)}
</a>
<a
href="https://github.com/orgs/PatchMon/projects/2/views/1"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
title="Roadmap"
>
<Route className="h-5 w-5" />
</a>
<a
href="https://patchmon.net/discord"
target="_blank"
@@ -895,7 +912,16 @@ const Layout = ({ children }) => {
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
title="Discord"
>
<MessageCircle className="h-5 w-5" />
<DiscordIcon className="h-5 w-5" />
</a>
<a
href="https://docs.patchmon.net"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
title="Documentation"
>
<BookOpen className="h-5 w-5" />
</a>
<a
href="mailto:support@patchmon.net"

View File

@@ -0,0 +1,44 @@
import { useQuery } from "@tanstack/react-query";
import { useTheme } from "../contexts/ThemeContext";
import { settingsAPI } from "../utils/api";
const Logo = ({
className = "h-8 w-auto",
alt = "PatchMon Logo",
...props
}) => {
const { isDark } = useTheme();
const { data: settings } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Determine which logo to use based on theme
const logoSrc = isDark
? settings?.logo_dark || "/assets/logo_dark.png"
: settings?.logo_light || "/assets/logo_light.png";
// Add cache-busting parameter using updated_at timestamp
const cacheBuster = settings?.updated_at
? new Date(settings.updated_at).getTime()
: Date.now();
const logoSrcWithCache = `${logoSrc}?v=${cacheBuster}`;
return (
<img
src={logoSrcWithCache}
alt={alt}
className={className}
onError={(e) => {
// Fallback to default logo if custom logo fails to load
e.target.src = isDark
? "/assets/logo_dark.png"
: "/assets/logo_light.png";
}}
{...props}
/>
);
};
export default Logo;

View File

@@ -0,0 +1,37 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect } from "react";
import { settingsAPI } from "../utils/api";
const LogoProvider = ({ children }) => {
const { data: settings } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
});
useEffect(() => {
// Use custom favicon or fallback to default
const faviconUrl = settings?.favicon || "/assets/favicon.svg";
// Add cache-busting parameter using updated_at timestamp
const cacheBuster = settings?.updated_at
? new Date(settings.updated_at).getTime()
: Date.now();
const faviconUrlWithCache = `${faviconUrl}?v=${cacheBuster}`;
// Update favicon
const favicon = document.querySelector('link[rel="icon"]');
if (favicon) {
favicon.href = faviconUrlWithCache;
} else {
// Create favicon link if it doesn't exist
const link = document.createElement("link");
link.rel = "icon";
link.href = faviconUrlWithCache;
document.head.appendChild(link);
}
}, [settings?.favicon, settings?.updated_at]);
return children;
};
export default LogoProvider;

View File

@@ -4,6 +4,7 @@ import {
ChevronRight,
Code,
Folder,
Image,
RefreshCw,
Settings,
Shield,
@@ -130,6 +131,11 @@ const SettingsLayout = ({ children }) => {
href: "/settings/server-url",
icon: Wrench,
},
{
name: "Branding",
href: "/settings/branding",
icon: Image,
},
{
name: "Server Version",
href: "/settings/server-version",

View File

@@ -0,0 +1,531 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, Image, RotateCcw, Upload, X } from "lucide-react";
import { useState } from "react";
import { settingsAPI } from "../../utils/api";
const BrandingTab = () => {
// Logo management state
const [logoUploadState, setLogoUploadState] = useState({
dark: { uploading: false, error: null },
light: { uploading: false, error: null },
favicon: { uploading: false, error: null },
});
const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
const [selectedLogoType, setSelectedLogoType] = useState("dark");
const queryClient = useQueryClient();
// Fetch current settings
const {
data: settings,
isLoading,
error,
} = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Logo upload mutation
const uploadLogoMutation = useMutation({
mutationFn: ({ logoType, fileContent, fileName }) =>
fetch("/api/v1/settings/logos/upload", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: JSON.stringify({ logoType, fileContent, fileName }),
}).then((res) => res.json()),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries(["settings"]);
setLogoUploadState((prev) => ({
...prev,
[variables.logoType]: { uploading: false, error: null },
}));
setShowLogoUploadModal(false);
},
onError: (error, variables) => {
console.error("Upload logo error:", error);
setLogoUploadState((prev) => ({
...prev,
[variables.logoType]: {
uploading: false,
error: error.message || "Failed to upload logo",
},
}));
},
});
// Logo reset mutation
const resetLogoMutation = useMutation({
mutationFn: (logoType) =>
fetch("/api/v1/settings/logos/reset", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: JSON.stringify({ logoType }),
}).then((res) => res.json()),
onSuccess: () => {
queryClient.invalidateQueries(["settings"]);
},
onError: (error) => {
console.error("Reset logo error:", error);
},
});
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Error loading settings
</h3>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{error.response?.data?.error || "Failed to load settings"}
</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center mb-6">
<Image className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
Logo & Branding
</h2>
</div>
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
Customize your PatchMon installation with custom logos and favicon.
These will be displayed throughout the application.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Dark Logo */}
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-4">
Dark Logo
</h4>
<div className="flex items-center justify-center p-4 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-4">
<img
src={`${settings?.logo_dark || "/assets/logo_dark.png"}?v=${Date.now()}`}
alt="Dark Logo"
className="max-h-16 max-w-full object-contain"
onError={(e) => {
e.target.src = "/assets/logo_dark.png";
}}
/>
</div>
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-4 truncate">
{settings?.logo_dark
? settings.logo_dark.split("/").pop()
: "logo_dark.png (Default)"}
</p>
<div className="space-y-2">
<button
type="button"
onClick={() => {
setSelectedLogoType("dark");
setShowLogoUploadModal(true);
}}
disabled={logoUploadState.dark.uploading}
className="w-full btn-outline flex items-center justify-center gap-2"
>
{logoUploadState.dark.uploading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
Uploading...
</>
) : (
<>
<Upload className="h-4 w-4" />
Upload Dark Logo
</>
)}
</button>
{settings?.logo_dark && (
<button
type="button"
onClick={() => resetLogoMutation.mutate("dark")}
disabled={resetLogoMutation.isPending}
className="w-full btn-outline flex items-center justify-center gap-2 text-orange-600 hover:text-orange-700 border-orange-300 hover:border-orange-400"
>
<RotateCcw className="h-4 w-4" />
Reset to Default
</button>
)}
</div>
{logoUploadState.dark.error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
{logoUploadState.dark.error}
</p>
)}
</div>
{/* Light Logo */}
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-4">
Light Logo
</h4>
<div className="flex items-center justify-center p-4 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-4">
<img
src={`${settings?.logo_light || "/assets/logo_light.png"}?v=${Date.now()}`}
alt="Light Logo"
className="max-h-16 max-w-full object-contain"
onError={(e) => {
e.target.src = "/assets/logo_light.png";
}}
/>
</div>
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-4 truncate">
{settings?.logo_light
? settings.logo_light.split("/").pop()
: "logo_light.png (Default)"}
</p>
<div className="space-y-2">
<button
type="button"
onClick={() => {
setSelectedLogoType("light");
setShowLogoUploadModal(true);
}}
disabled={logoUploadState.light.uploading}
className="w-full btn-outline flex items-center justify-center gap-2"
>
{logoUploadState.light.uploading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
Uploading...
</>
) : (
<>
<Upload className="h-4 w-4" />
Upload Light Logo
</>
)}
</button>
{settings?.logo_light && (
<button
type="button"
onClick={() => resetLogoMutation.mutate("light")}
disabled={resetLogoMutation.isPending}
className="w-full btn-outline flex items-center justify-center gap-2 text-orange-600 hover:text-orange-700 border-orange-300 hover:border-orange-400"
>
<RotateCcw className="h-4 w-4" />
Reset to Default
</button>
)}
</div>
{logoUploadState.light.error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
{logoUploadState.light.error}
</p>
)}
</div>
{/* Favicon */}
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-4">
Favicon
</h4>
<div className="flex items-center justify-center p-4 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-4">
<img
src={`${settings?.favicon || "/assets/favicon.svg"}?v=${Date.now()}`}
alt="Favicon"
className="h-8 w-8 object-contain"
onError={(e) => {
e.target.src = "/assets/favicon.svg";
}}
/>
</div>
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-4 truncate">
{settings?.favicon
? settings.favicon.split("/").pop()
: "favicon.svg (Default)"}
</p>
<div className="space-y-2">
<button
type="button"
onClick={() => {
setSelectedLogoType("favicon");
setShowLogoUploadModal(true);
}}
disabled={logoUploadState.favicon.uploading}
className="w-full btn-outline flex items-center justify-center gap-2"
>
{logoUploadState.favicon.uploading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
Uploading...
</>
) : (
<>
<Upload className="h-4 w-4" />
Upload Favicon
</>
)}
</button>
{settings?.favicon && (
<button
type="button"
onClick={() => resetLogoMutation.mutate("favicon")}
disabled={resetLogoMutation.isPending}
className="w-full btn-outline flex items-center justify-center gap-2 text-orange-600 hover:text-orange-700 border-orange-300 hover:border-orange-400"
>
<RotateCcw className="h-4 w-4" />
Reset to Default
</button>
)}
</div>
{logoUploadState.favicon.error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
{logoUploadState.favicon.error}
</p>
)}
</div>
</div>
{/* Usage Instructions */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-md p-4 mt-6">
<div className="flex">
<Image className="h-5 w-5 text-blue-400 dark:text-blue-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200">
Logo Usage
</h3>
<div className="mt-2 text-sm text-blue-700 dark:text-blue-300">
<p className="mb-2">
These logos are used throughout the application:
</p>
<ul className="list-disc list-inside space-y-1">
<li>
<strong>Dark Logo:</strong> Used in dark mode and on light
backgrounds
</li>
<li>
<strong>Light Logo:</strong> Used in light mode and on dark
backgrounds
</li>
<li>
<strong>Favicon:</strong> Used as the browser tab icon (SVG
recommended)
</li>
</ul>
<p className="mt-3 text-xs">
<strong>Supported formats:</strong> PNG, JPG, SVG |{" "}
<strong>Max size:</strong> 5MB |{" "}
<strong>Recommended sizes:</strong> 200x60px for logos, 32x32px
for favicon.
</p>
</div>
</div>
</div>
</div>
{/* Logo Upload Modal */}
{showLogoUploadModal && (
<LogoUploadModal
isOpen={showLogoUploadModal}
onClose={() => setShowLogoUploadModal(false)}
onSubmit={uploadLogoMutation.mutate}
isLoading={uploadLogoMutation.isPending}
error={uploadLogoMutation.error}
logoType={selectedLogoType}
/>
)}
</div>
);
};
// Logo Upload Modal Component
const LogoUploadModal = ({
isOpen,
onClose,
onSubmit,
isLoading,
error,
logoType,
}) => {
const [selectedFile, setSelectedFile] = useState(null);
const [previewUrl, setPreviewUrl] = useState(null);
const [uploadError, setUploadError] = useState("");
const handleFileSelect = (e) => {
const file = e.target.files[0];
if (file) {
// Validate file type
const allowedTypes = [
"image/png",
"image/jpeg",
"image/jpg",
"image/svg+xml",
];
if (!allowedTypes.includes(file.type)) {
setUploadError("Please select a PNG, JPG, or SVG file");
return;
}
// Validate file size (5MB limit)
if (file.size > 5 * 1024 * 1024) {
setUploadError("File size must be less than 5MB");
return;
}
setSelectedFile(file);
setUploadError("");
// Create preview URL
const url = URL.createObjectURL(file);
setPreviewUrl(url);
}
};
const handleSubmit = (e) => {
e.preventDefault();
setUploadError("");
if (!selectedFile) {
setUploadError("Please select a file");
return;
}
// Convert file to base64
const reader = new FileReader();
reader.onload = (event) => {
const base64 = event.target.result;
onSubmit({
logoType,
fileContent: base64,
fileName: selectedFile.name,
});
};
reader.readAsDataURL(selectedFile);
};
const handleClose = () => {
setSelectedFile(null);
setPreviewUrl(null);
setUploadError("");
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Upload{" "}
{logoType === "favicon"
? "Favicon"
: `${logoType.charAt(0).toUpperCase() + logoType.slice(1)} Logo`}
</h3>
<button
type="button"
onClick={handleClose}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="px-6 py-4">
<div className="space-y-4">
<div>
<label className="block">
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
Select File
</span>
<input
type="file"
accept="image/png,image/jpeg,image/jpg,image/svg+xml"
onChange={handleFileSelect}
className="block w-full text-sm text-secondary-500 dark:text-secondary-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900 dark:file:text-primary-200"
/>
</label>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Supported formats: PNG, JPG, SVG. Max size: 5MB.
{logoType === "favicon"
? " Recommended: 32x32px SVG."
: " Recommended: 200x60px."}
</p>
</div>
{previewUrl && (
<div>
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
Preview
</div>
<div className="flex items-center justify-center p-4 bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-600">
<img
src={previewUrl}
alt="Preview"
className={`object-contain ${
logoType === "favicon" ? "h-8 w-8" : "max-h-16 max-w-full"
}`}
/>
</div>
</div>
)}
{(uploadError || error) && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
<p className="text-sm text-red-800 dark:text-red-200">
{uploadError ||
error?.response?.data?.error ||
error?.message}
</p>
</div>
)}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3">
<div className="flex">
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mr-2 mt-0.5" />
<div className="text-sm text-yellow-800 dark:text-yellow-200">
<p className="font-medium">Important:</p>
<ul className="mt-1 list-disc list-inside space-y-1">
<li>This will replace the current {logoType} logo</li>
<li>A backup will be created automatically</li>
<li>The change will be applied immediately</li>
</ul>
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button type="button" onClick={handleClose} className="btn-outline">
Cancel
</button>
<button
type="submit"
disabled={isLoading || !selectedFile}
className="btn-primary"
>
{isLoading ? "Uploading..." : "Upload Logo"}
</button>
</div>
</form>
</div>
</div>
);
};
export default BrandingTab;

View File

@@ -1,30 +1,16 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle,
Clock,
Code,
Download,
Save,
ExternalLink,
GitCommit,
} from "lucide-react";
import { useEffect, useId, useState } from "react";
import { settingsAPI, versionAPI } from "../../utils/api";
import { useCallback, useEffect, useState } from "react";
import { versionAPI } from "../../utils/api";
const VersionUpdateTab = () => {
const repoPublicId = useId();
const repoPrivateId = useId();
const useCustomSshKeyId = useId();
const githubRepoUrlId = useId();
const sshKeyPathId = useId();
const [formData, setFormData] = useState({
githubRepoUrl: "git@github.com:9technologygroup/patchmon.net.git",
repositoryType: "public",
sshKeyPath: "",
useCustomSshKey: false,
});
const [errors, setErrors] = useState({});
const [isDirty, setIsDirty] = useState(false);
// Version checking state
const [versionInfo, setVersionInfo] = useState({
currentVersion: null,
@@ -32,89 +18,11 @@ const VersionUpdateTab = () => {
isUpdateAvailable: false,
checking: false,
error: null,
github: null,
});
const [sshTestResult, setSshTestResult] = useState({
testing: false,
success: null,
message: null,
error: null,
});
const queryClient = useQueryClient();
// Fetch current settings
const {
data: settings,
isLoading,
error,
} = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Update form data when settings are loaded
useEffect(() => {
if (settings) {
const newFormData = {
githubRepoUrl:
settings.github_repo_url ||
"git@github.com:9technologygroup/patchmon.net.git",
repositoryType: settings.repository_type || "public",
sshKeyPath: settings.ssh_key_path || "",
useCustomSshKey: !!settings.ssh_key_path,
};
setFormData(newFormData);
setIsDirty(false);
}
}, [settings]);
// Update settings mutation
const updateSettingsMutation = useMutation({
mutationFn: (data) => {
return settingsAPI.update(data).then((res) => res.data);
},
onSuccess: () => {
queryClient.invalidateQueries(["settings"]);
setIsDirty(false);
setErrors({});
},
onError: (error) => {
if (error.response?.data?.errors) {
setErrors(
error.response.data.errors.reduce((acc, err) => {
acc[err.path] = err.msg;
return acc;
}, {}),
);
} else {
setErrors({
general: error.response?.data?.error || "Failed to update settings",
});
}
},
});
// Load current version on component mount
useEffect(() => {
const loadCurrentVersion = async () => {
try {
const response = await versionAPI.getCurrent();
const data = response.data;
setVersionInfo((prev) => ({
...prev,
currentVersion: data.version,
}));
} catch (error) {
console.error("Error loading current version:", error);
}
};
loadCurrentVersion();
}, []);
// Version checking functions
const checkForUpdates = async () => {
const checkForUpdates = useCallback(async () => {
setVersionInfo((prev) => ({ ...prev, checking: true, error: null }));
try {
@@ -126,6 +34,7 @@ const VersionUpdateTab = () => {
latestVersion: data.latestVersion,
isUpdateAvailable: data.isUpdateAvailable,
last_update_check: data.last_update_check,
github: data.github,
checking: false,
error: null,
});
@@ -137,434 +46,274 @@ const VersionUpdateTab = () => {
error: error.response?.data?.error || "Failed to check for updates",
}));
}
};
}, []);
const testSshKey = async () => {
if (!formData.sshKeyPath || !formData.githubRepoUrl) {
setSshTestResult({
testing: false,
success: false,
message: null,
error: "Please enter both SSH key path and GitHub repository URL",
});
return;
}
// Load current version and automatically check for updates on component mount
useEffect(() => {
const loadAndCheckUpdates = async () => {
try {
// First, get current version info
const response = await versionAPI.getCurrent();
const data = response.data;
setVersionInfo({
currentVersion: data.version,
latestVersion: data.latest_version || null,
isUpdateAvailable: data.is_update_available || false,
last_update_check: data.last_update_check || null,
github: data.github,
checking: false,
error: null,
});
setSshTestResult({
testing: true,
success: null,
message: null,
error: null,
});
// Then automatically trigger a fresh update check
await checkForUpdates();
} catch (error) {
console.error("Error loading version info:", error);
setVersionInfo((prev) => ({
...prev,
error: "Failed to load version information",
}));
}
};
try {
const response = await versionAPI.testSshKey({
sshKeyPath: formData.sshKeyPath,
githubRepoUrl: formData.githubRepoUrl,
});
setSshTestResult({
testing: false,
success: true,
message: response.data.message,
error: null,
});
} catch (error) {
console.error("SSH key test error:", error);
setSshTestResult({
testing: false,
success: false,
message: null,
error: error.response?.data?.error || "Failed to test SSH key",
});
}
};
const handleInputChange = (field, value) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
setIsDirty(true);
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: null }));
}
};
const handleSave = () => {
// Only include sshKeyPath if the toggle is enabled
const dataToSubmit = { ...formData };
if (!dataToSubmit.useCustomSshKey) {
dataToSubmit.sshKeyPath = "";
}
// Remove the frontend-only field
delete dataToSubmit.useCustomSshKey;
updateSettingsMutation.mutate(dataToSubmit);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Error loading settings
</h3>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{error.response?.data?.error || "Failed to load settings"}
</p>
</div>
</div>
</div>
);
}
loadAndCheckUpdates();
}, [checkForUpdates]); // Run when component mounts
return (
<div className="space-y-6">
{errors.general && (
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<p className="text-sm text-red-700 dark:text-red-300">
{errors.general}
</p>
</div>
</div>
</div>
)}
<div className="flex items-center mb-6">
<Code className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
Server Version Management
Server Version Information
</h2>
</div>
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Version Check Configuration
Version Information
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-6">
Configure automatic version checking against your GitHub repository to
notify users of available updates.
Current server version and latest updates from GitHub repository.
{versionInfo.checking && (
<span className="ml-2 text-blue-600 dark:text-blue-400">
🔄 Checking for updates...
</span>
)}
</p>
<div className="space-y-4">
<fieldset>
<legend className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
Repository Type
</legend>
<div className="space-y-2">
<div className="flex items-center">
<input
type="radio"
id={repoPublicId}
name="repositoryType"
value="public"
checked={formData.repositoryType === "public"}
onChange={(e) =>
handleInputChange("repositoryType", e.target.value)
}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300"
/>
<label
htmlFor={repoPublicId}
className="ml-2 text-sm text-secondary-700 dark:text-secondary-200"
>
Public Repository (uses GitHub API - no authentication
required)
</label>
</div>
<div className="flex items-center">
<input
type="radio"
id={repoPrivateId}
name="repositoryType"
value="private"
checked={formData.repositoryType === "private"}
onChange={(e) =>
handleInputChange("repositoryType", e.target.value)
}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300"
/>
<label
htmlFor={repoPrivateId}
className="ml-2 text-sm text-secondary-700 dark:text-secondary-200"
>
Private Repository (uses SSH with deploy key)
</label>
</div>
</div>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Choose whether your repository is public or private to determine
the appropriate access method.
</p>
</fieldset>
<div>
<label
htmlFor={githubRepoUrlId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
GitHub Repository URL
</label>
<input
id={githubRepoUrlId}
type="text"
value={formData.githubRepoUrl || ""}
onChange={(e) =>
handleInputChange("githubRepoUrl", e.target.value)
}
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm"
placeholder="git@github.com:username/repository.git"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
SSH or HTTPS URL to your GitHub repository
</p>
</div>
{formData.repositoryType === "private" && (
<div>
<div className="flex items-center gap-3 mb-3">
<input
type="checkbox"
id={useCustomSshKeyId}
checked={formData.useCustomSshKey}
onChange={(e) => {
const checked = e.target.checked;
handleInputChange("useCustomSshKey", checked);
if (!checked) {
handleInputChange("sshKeyPath", "");
}
}}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label
htmlFor={useCustomSshKeyId}
className="text-sm font-medium text-secondary-700 dark:text-secondary-200"
>
Set custom SSH key path
</label>
</div>
{formData.useCustomSshKey && (
<div>
<label
htmlFor={sshKeyPathId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
SSH Key Path
</label>
<input
id={sshKeyPathId}
type="text"
value={formData.sshKeyPath || ""}
onChange={(e) =>
handleInputChange("sshKeyPath", e.target.value)
}
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm"
placeholder="/root/.ssh/id_ed25519"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Path to your SSH deploy key. If not set, will auto-detect
from common locations.
</p>
<div className="mt-3">
<button
type="button"
onClick={testSshKey}
disabled={
sshTestResult.testing ||
!formData.sshKeyPath ||
!formData.githubRepoUrl
}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{sshTestResult.testing ? "Testing..." : "Test SSH Key"}
</button>
{sshTestResult.success && (
<div className="mt-2 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md">
<div className="flex items-center">
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400 mr-2" />
<p className="text-sm text-green-800 dark:text-green-200">
{sshTestResult.message}
</p>
</div>
</div>
)}
{sshTestResult.error && (
<div className="mt-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<div className="flex items-center">
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400 mr-2" />
<p className="text-sm text-red-800 dark:text-red-200">
{sshTestResult.error}
</p>
</div>
</div>
)}
</div>
</div>
)}
{!formData.useCustomSshKey && (
<p className="text-xs text-secondary-500 dark:text-secondary-400">
Using auto-detection for SSH key location
</p>
)}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Current Version
</span>
</div>
<span className="text-lg font-mono text-secondary-900 dark:text-white">
{versionInfo.currentVersion}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* My Version */}
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
My Version
</span>
</div>
<span className="text-lg font-mono text-secondary-900 dark:text-white">
{versionInfo.currentVersion}
</span>
</div>
{/* Latest Release */}
{versionInfo.github?.latestRelease && (
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-2 mb-2">
<Download className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Latest Version
Latest Release
</span>
</div>
<span className="text-lg font-mono text-secondary-900 dark:text-white">
{versionInfo.checking ? (
<span className="text-blue-600 dark:text-blue-400">
Checking...
</span>
) : versionInfo.latestVersion ? (
<span
className={
versionInfo.isUpdateAvailable
? "text-orange-600 dark:text-orange-400"
: "text-green-600 dark:text-green-400"
}
>
{versionInfo.latestVersion}
{versionInfo.isUpdateAvailable && " (Update Available!)"}
</span>
) : (
<span className="text-secondary-500 dark:text-secondary-400">
Not checked
</span>
)}
</span>
</div>
</div>
{/* Last Checked Time */}
{versionInfo.last_update_check && (
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-2 mb-2">
<Clock className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Last Checked
<div className="space-y-1">
<span className="text-lg font-mono text-secondary-900 dark:text-white">
{versionInfo.github.latestRelease.tagName}
</span>
</div>
<span className="text-sm text-secondary-600 dark:text-secondary-400">
{new Date(versionInfo.last_update_check).toLocaleString()}
</span>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
Updates are checked automatically every 24 hours
</p>
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
type="button"
onClick={checkForUpdates}
disabled={versionInfo.checking}
className="btn-primary flex items-center gap-2"
>
<Download className="h-4 w-4" />
{versionInfo.checking ? "Checking..." : "Check for Updates"}
</button>
</div>
{/* Save Button for Version Settings */}
<button
type="button"
onClick={handleSave}
disabled={!isDirty || updateSettingsMutation.isPending}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${
!isDirty || updateSettingsMutation.isPending
? "bg-secondary-400 cursor-not-allowed"
: "bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
}`}
>
{updateSettingsMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Settings
</>
)}
</button>
</div>
{versionInfo.error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-lg p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Version Check Failed
</h3>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{versionInfo.error}
</p>
{versionInfo.error.includes("private") && (
<p className="mt-2 text-xs text-red-600 dark:text-red-400">
For private repositories, you may need to configure GitHub
authentication or make the repository public.
</p>
)}
</div>
</div>
</div>
)}
{/* Success Message for Version Settings */}
{updateSettingsMutation.isSuccess && (
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-4">
<div className="flex">
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
<div className="ml-3">
<p className="text-sm text-green-700 dark:text-green-300">
Settings saved successfully!
</p>
<div className="text-xs text-secondary-500 dark:text-secondary-400">
Published:{" "}
{new Date(
versionInfo.github.latestRelease.publishedAt,
).toLocaleDateString()}
</div>
</div>
</div>
)}
</div>
{/* GitHub Repository Information */}
{versionInfo.github && (
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600 mt-4">
<div className="flex items-center gap-2 mb-4">
<Code className="h-4 w-4 text-purple-600 dark:text-purple-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
GitHub Repository Information
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Repository URL */}
<div className="space-y-2">
<span className="text-xs font-medium text-secondary-600 dark:text-secondary-400 uppercase tracking-wide">
Repository
</span>
<div className="flex items-center gap-2">
<span className="text-sm text-secondary-900 dark:text-white font-mono">
{versionInfo.github.owner}/{versionInfo.github.repo}
</span>
{versionInfo.github.repository && (
<a
href={versionInfo.github.repository}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
</div>
{/* Latest Release Info */}
{versionInfo.github.latestRelease && (
<div className="space-y-2">
<span className="text-xs font-medium text-secondary-600 dark:text-secondary-400 uppercase tracking-wide">
Release Link
</span>
<div className="flex items-center gap-2">
{versionInfo.github.latestRelease.htmlUrl && (
<a
href={versionInfo.github.latestRelease.htmlUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm"
>
View Release{" "}
<ExternalLink className="h-3 w-3 inline ml-1" />
</a>
)}
</div>
</div>
)}
{/* Branch Status */}
{versionInfo.github.commitDifference && (
<div className="space-y-2">
<span className="text-xs font-medium text-secondary-600 dark:text-secondary-400 uppercase tracking-wide">
Branch Status
</span>
<div className="text-sm">
{versionInfo.github.commitDifference.commitsAhead > 0 ? (
<span className="text-blue-600 dark:text-blue-400">
🚀 Main branch is{" "}
{versionInfo.github.commitDifference.commitsAhead}{" "}
commits ahead of release
</span>
) : versionInfo.github.commitDifference.commitsBehind >
0 ? (
<span className="text-orange-600 dark:text-orange-400">
📊 Main branch is{" "}
{versionInfo.github.commitDifference.commitsBehind}{" "}
commits behind release
</span>
) : (
<span className="text-green-600 dark:text-green-400">
Main branch is in sync with release
</span>
)}
</div>
</div>
)}
</div>
{/* Latest Commit Information */}
{versionInfo.github.latestCommit && (
<div className="mt-4 pt-4 border-t border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-2 mb-2">
<GitCommit className="h-4 w-4 text-orange-600 dark:text-orange-400" />
<span className="text-xs font-medium text-secondary-600 dark:text-secondary-400 uppercase tracking-wide">
Latest Commit (Rolling)
</span>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-secondary-900 dark:text-white">
{versionInfo.github.latestCommit.sha.substring(0, 8)}
</span>
{versionInfo.github.latestCommit.htmlUrl && (
<a
href={versionInfo.github.latestCommit.htmlUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
<p className="text-sm text-secondary-700 dark:text-secondary-300">
{versionInfo.github.latestCommit.message.split("\n")[0]}
</p>
<div className="flex items-center gap-4 text-xs text-secondary-500 dark:text-secondary-400">
<span>
Author: {versionInfo.github.latestCommit.author}
</span>
<span>
Date:{" "}
{new Date(
versionInfo.github.latestCommit.date,
).toLocaleString()}
</span>
</div>
</div>
</div>
)}
</div>
)}
{/* Last Checked Time */}
{versionInfo.last_update_check && (
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600 mt-4">
<div className="flex items-center gap-2 mb-2">
<Clock className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Last Checked
</span>
</div>
<span className="text-sm text-secondary-600 dark:text-secondary-400">
{new Date(versionInfo.last_update_check).toLocaleString()}
</span>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
Updates are checked automatically every 24 hours
</p>
</div>
)}
<div className="flex items-center justify-start mt-6">
<button
type="button"
onClick={checkForUpdates}
disabled={versionInfo.checking}
className="btn-primary flex items-center gap-2"
>
<Download className="h-4 w-4" />
{versionInfo.checking ? "Checking..." : "Check for Updates"}
</button>
</div>
{versionInfo.error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-lg p-4 mt-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Version Check Failed
</h3>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{versionInfo.error}
</p>
</div>
</div>
</div>
)}
</div>
</div>
);

View File

@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { createContext, useContext, useMemo, useState } from "react";
import { createContext, useContext, useState } from "react";
import { isAuthReady } from "../constants/authPhases";
import { settingsAPI, versionAPI } from "../utils/api";
import { settingsAPI } from "../utils/api";
import { useAuth } from "./AuthContext";
const UpdateNotificationContext = createContext();
@@ -21,6 +21,7 @@ export const UpdateNotificationProvider = ({ children }) => {
const { authPhase, isAuthenticated } = useAuth();
// Ensure settings are loaded - but only after auth is fully ready
// This reads cached update info from backend (updated by scheduler)
const { data: settings, isLoading: settingsLoading } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
@@ -29,31 +30,20 @@ export const UpdateNotificationProvider = ({ children }) => {
enabled: isAuthReady(authPhase, isAuthenticated()),
});
// Memoize the enabled condition to prevent unnecessary re-evaluations
const isQueryEnabled = useMemo(() => {
return (
isAuthReady(authPhase, isAuthenticated()) &&
!!settings &&
!settingsLoading
);
}, [authPhase, isAuthenticated, settings, settingsLoading]);
// Read cached update information from settings (no GitHub API calls)
// The backend scheduler updates this data periodically
const updateAvailable = settings?.is_update_available && !dismissed;
const updateInfo = settings
? {
isUpdateAvailable: settings.is_update_available,
latestVersion: settings.latest_version,
currentVersion: settings.current_version,
last_update_check: settings.last_update_check,
}
: null;
// Query for update information
const {
data: updateData,
isLoading,
error,
} = useQuery({
queryKey: ["updateCheck"],
queryFn: () => versionAPI.checkUpdates().then((res) => res.data),
staleTime: 10 * 60 * 1000, // Data stays fresh for 10 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus
retry: 1,
enabled: isQueryEnabled,
});
const updateAvailable = updateData?.isUpdateAvailable && !dismissed;
const updateInfo = updateData;
const isLoading = settingsLoading;
const error = null;
const dismissNotification = () => {
setDismissed(true);

View File

@@ -45,6 +45,7 @@ const HostDetail = () => {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showAllUpdates, setShowAllUpdates] = useState(false);
const [activeTab, setActiveTab] = useState("host");
const [_forceInstall, _setForceInstall] = useState(false);
const {
data: host,
@@ -387,6 +388,17 @@ const HostDetail = () => {
</div>
)}
{host.machine_id && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
Machine ID
</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm break-all">
{host.machine_id}
</p>
</div>
)}
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
Host Group
@@ -455,11 +467,23 @@ const HostDetail = () => {
{/* Network Information */}
{activeTab === "network" &&
(host.gateway_ip ||
(host.ip ||
host.gateway_ip ||
host.dns_servers ||
host.network_interfaces) && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{host.ip && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
IP Address
</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">
{host.ip}
</p>
</div>
)}
{host.gateway_ip && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
@@ -791,6 +815,7 @@ const HostDetail = () => {
{activeTab === "network" &&
!(
host.ip ||
host.gateway_ip ||
host.dns_servers ||
host.network_interfaces
@@ -1059,6 +1084,7 @@ const HostDetail = () => {
const CredentialsModal = ({ host, isOpen, onClose }) => {
const [showApiKey, setShowApiKey] = useState(false);
const [activeTab, setActiveTab] = useState("quick-install");
const [forceInstall, setForceInstall] = useState(false);
const apiIdInputId = useId();
const apiKeyInputId = useId();
@@ -1080,6 +1106,12 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
};
// Helper function to build installation URL with optional force flag
const getInstallUrl = () => {
const baseUrl = `${serverUrl}/api/v1/hosts/install`;
return forceInstall ? `${baseUrl}?force=true` : baseUrl;
};
const copyToClipboard = async (text) => {
try {
// Try modern clipboard API first
@@ -1173,10 +1205,30 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
Copy and run this command on the target host to securely install
and configure the PatchMon agent:
</p>
{/* Force Install Toggle */}
<div className="mb-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={forceInstall}
onChange={(e) => setForceInstall(e.target.checked)}
className="rounded border-secondary-300 dark:border-secondary-600 text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-400 dark:bg-secondary-700"
/>
<span className="text-primary-800 dark:text-primary-200">
Force install (bypass broken packages)
</span>
</label>
<p className="text-xs text-primary-600 dark:text-primary-400 mt-1">
Enable this if the target host has broken packages
(CloudPanel, WHM, etc.) that block apt-get operations
</p>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={`curl ${getCurlFlags()} ${serverUrl}/api/v1/hosts/install -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | bash`}
value={`curl ${getCurlFlags()} ${getInstallUrl()} -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | bash`}
readOnly
className="flex-1 px-3 py-2 border border-primary-300 dark:border-primary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
@@ -1184,7 +1236,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
type="button"
onClick={() =>
copyToClipboard(
`curl ${getCurlFlags()} ${serverUrl}/api/v1/hosts/install -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | bash`,
`curl ${getCurlFlags()} ${getInstallUrl()} -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | bash`,
)
}
className="btn-primary flex items-center gap-1"

View File

@@ -1,23 +1,476 @@
import { Package } from "lucide-react";
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowLeft,
Calendar,
ChartColumnBig,
ChevronRight,
Download,
Package,
RefreshCw,
Search,
Server,
Shield,
Tag,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { formatRelativeTime, packagesAPI } from "../utils/api";
const PackageDetail = () => {
const { packageId } = useParams();
const decodedPackageId = decodeURIComponent(packageId || "");
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
// Fetch package details
const {
data: packageData,
isLoading: isLoadingPackage,
error: packageError,
refetch: refetchPackage,
} = useQuery({
queryKey: ["package", decodedPackageId],
queryFn: () =>
packagesAPI.getById(decodedPackageId).then((res) => res.data),
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
enabled: !!decodedPackageId,
});
// Fetch hosts that have this package
const {
data: hostsData,
isLoading: isLoadingHosts,
error: hostsError,
refetch: refetchHosts,
} = useQuery({
queryKey: ["package-hosts", decodedPackageId, searchTerm],
queryFn: () =>
packagesAPI
.getHosts(decodedPackageId, { search: searchTerm, limit: 1000 })
.then((res) => res.data),
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
enabled: !!decodedPackageId,
});
const hosts = hostsData?.hosts || [];
// Filter and paginate hosts
const filteredAndPaginatedHosts = useMemo(() => {
let filtered = hosts;
if (searchTerm) {
filtered = hosts.filter(
(host) =>
host.friendlyName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.hostname?.toLowerCase().includes(searchTerm.toLowerCase()),
);
}
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
return filtered.slice(startIndex, endIndex);
}, [hosts, searchTerm, currentPage, pageSize]);
const totalPages = Math.ceil(
(searchTerm
? hosts.filter(
(host) =>
host.friendlyName
?.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
host.hostname?.toLowerCase().includes(searchTerm.toLowerCase()),
).length
: hosts.length) / pageSize,
);
const handleHostClick = (hostId) => {
navigate(`/hosts/${hostId}`);
};
const handleRefresh = () => {
refetchPackage();
refetchHosts();
};
if (isLoadingPackage) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-primary-600" />
</div>
);
}
if (packageError) {
return (
<div className="space-y-6">
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">
Error loading package
</h3>
<p className="text-sm text-danger-700 mt-1">
{packageError.message || "Failed to load package details"}
</p>
<button
type="button"
onClick={() => refetchPackage()}
className="mt-2 btn-danger text-xs"
>
Try again
</button>
</div>
</div>
</div>
</div>
);
}
if (!packageData) {
return (
<div className="space-y-6">
<div className="text-center py-8">
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
Package not found
</p>
</div>
</div>
);
}
const pkg = packageData;
const stats = packageData.stats || {};
return (
<div className="space-y-6">
<div className="card p-8 text-center">
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-secondary-900 mb-2">
Package Details
</h3>
<p className="text-secondary-600">
Detailed view for package: {packageId}
</p>
<p className="text-secondary-600 mt-2">
This page will show package information, affected hosts, version
distribution, and more.
</p>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => navigate("/packages")}
className="flex items-center gap-2 text-secondary-600 hover:text-secondary-900 dark:text-secondary-400 dark:hover:text-white transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Back to Packages
</button>
<ChevronRight className="h-4 w-4 text-secondary-400" />
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
{pkg.name}
</h1>
</div>
<button
type="button"
onClick={handleRefresh}
disabled={isLoadingPackage || isLoadingHosts}
className="btn-outline flex items-center gap-2"
>
<RefreshCw
className={`h-4 w-4 ${
isLoadingPackage || isLoadingHosts ? "animate-spin" : ""
}`}
/>
Refresh
</button>
</div>
{/* Package Overview */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Package Info */}
<div className="lg:col-span-2">
<div className="card p-6">
<div className="flex items-start gap-4 mb-4">
<Package className="h-8 w-8 text-primary-600 flex-shrink-0 mt-1" />
<div className="flex-1">
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white mb-2">
{pkg.name}
</h2>
{pkg.description && (
<p className="text-secondary-600 dark:text-secondary-300 mb-4">
{pkg.description}
</p>
)}
<div className="flex flex-wrap gap-4 text-sm">
{pkg.category && (
<div className="flex items-center gap-2">
<Tag className="h-4 w-4 text-secondary-400" />
<span className="text-secondary-600 dark:text-secondary-300">
Category: {pkg.category}
</span>
</div>
)}
{pkg.latest_version && (
<div className="flex items-center gap-2">
<Download className="h-4 w-4 text-secondary-400" />
<span className="text-secondary-600 dark:text-secondary-300">
Latest: {pkg.latest_version}
</span>
</div>
)}
{pkg.updated_at && (
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-secondary-400" />
<span className="text-secondary-600 dark:text-secondary-300">
Updated: {formatRelativeTime(pkg.updated_at)}
</span>
</div>
)}
</div>
</div>
</div>
{/* Status Badge */}
<div className="mb-4">
{stats.updatesNeeded > 0 ? (
stats.securityUpdates > 0 ? (
<span className="badge-danger flex items-center gap-1 w-fit">
<Shield className="h-3 w-3" />
Security Update Available
</span>
) : (
<span className="badge-warning w-fit">Update Available</span>
)
) : (
<span className="badge-success w-fit">Up to Date</span>
)}
</div>
</div>
</div>
{/* Statistics */}
<div className="space-y-4">
<div className="card p-4">
<div className="flex items-center gap-3 mb-3">
<ChartColumnBig className="h-5 w-5 text-primary-600" />
<h3 className="font-medium text-secondary-900 dark:text-white">
Installation Stats
</h3>
</div>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-secondary-600 dark:text-secondary-300">
Total Installations
</span>
<span className="font-semibold text-secondary-900 dark:text-white">
{stats.totalInstalls || 0}
</span>
</div>
{stats.updatesNeeded > 0 && (
<div className="flex justify-between">
<span className="text-secondary-600 dark:text-secondary-300">
Hosts Needing Updates
</span>
<span className="font-semibold text-warning-600">
{stats.updatesNeeded}
</span>
</div>
)}
{stats.securityUpdates > 0 && (
<div className="flex justify-between">
<span className="text-secondary-600 dark:text-secondary-300">
Security Updates
</span>
<span className="font-semibold text-danger-600">
{stats.securityUpdates}
</span>
</div>
)}
<div className="flex justify-between">
<span className="text-secondary-600 dark:text-secondary-300">
Up to Date
</span>
<span className="font-semibold text-success-600">
{(stats.totalInstalls || 0) - (stats.updatesNeeded || 0)}
</span>
</div>
</div>
</div>
</div>
</div>
{/* Hosts List */}
<div className="card">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-primary-600" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Installed On Hosts ({hosts.length})
</h3>
</div>
</div>
{/* Search */}
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400" />
<input
type="text"
placeholder="Search hosts..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
/>
</div>
</div>
<div className="overflow-x-auto">
{isLoadingHosts ? (
<div className="flex items-center justify-center h-32">
<RefreshCw className="h-6 w-6 animate-spin text-primary-600" />
</div>
) : hostsError ? (
<div className="p-6">
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">
Error loading hosts
</h3>
<p className="text-sm text-danger-700 mt-1">
{hostsError.message || "Failed to load hosts"}
</p>
</div>
</div>
</div>
</div>
) : filteredAndPaginatedHosts.length === 0 ? (
<div className="text-center py-8">
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
{searchTerm
? "No hosts match your search"
: "No hosts have this package installed"}
</p>
</div>
) : (
<>
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Host
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Current Version
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Last Updated
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredAndPaginatedHosts.map((host) => (
<tr
key={host.hostId}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 cursor-pointer transition-colors"
onClick={() => handleHostClick(host.hostId)}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Server className="h-5 w-5 text-secondary-400 mr-3" />
<div>
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{host.friendlyName || host.hostname}
</div>
{host.friendlyName && host.hostname && (
<div className="text-sm text-secondary-500 dark:text-secondary-300">
{host.hostname}
</div>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
{host.currentVersion || "Unknown"}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{host.needsUpdate ? (
host.isSecurityUpdate ? (
<span className="badge-danger flex items-center gap-1 w-fit">
<Shield className="h-3 w-3" />
Security Update
</span>
) : (
<span className="badge-warning w-fit">
Update Available
</span>
)
) : (
<span className="badge-success w-fit">
Up to Date
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-300">
{host.lastUpdate
? formatRelativeTime(host.lastUpdate)
: "Never"}
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
{totalPages > 1 && (
<div className="px-6 py-3 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-600 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-secondary-700 dark:text-secondary-300">
Rows per page:
</span>
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value));
setCurrentPage(1);
}}
className="text-sm border border-secondary-300 dark:border-secondary-600 rounded px-2 py-1 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
className="px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-secondary-50 dark:hover:bg-secondary-700"
>
Previous
</button>
<span className="text-sm text-secondary-700 dark:text-secondary-300">
Page {currentPage} of {totalPages}
</span>
<button
type="button"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-secondary-50 dark:hover:bg-secondary-700"
>
Next
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
);

View File

@@ -4,6 +4,8 @@ import {
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronLeft,
ChevronRight,
Columns,
Eye as EyeIcon,
EyeOff as EyeOffIcon,
@@ -17,16 +19,28 @@ import {
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { dashboardAPI } from "../utils/api";
import { dashboardAPI, packagesAPI } from "../utils/api";
const Packages = () => {
const [searchTerm, setSearchTerm] = useState("");
const [categoryFilter, setCategoryFilter] = useState("all");
const [securityFilter, setSecurityFilter] = useState("all");
const [updateStatusFilter, setUpdateStatusFilter] = useState("all-packages");
const [hostFilter, setHostFilter] = useState("all");
const [sortField, setSortField] = useState("name");
const [sortDirection, setSortDirection] = useState("asc");
const [showColumnSettings, setShowColumnSettings] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(() => {
const saved = localStorage.getItem("packages-page-size");
if (saved) {
const parsedSize = parseInt(saved, 10);
// Validate that the saved page size is one of the allowed values
if ([25, 50, 100, 200].includes(parsedSize)) {
return parsedSize;
}
}
return 25; // Default fallback
});
const [searchParams] = useSearchParams();
const navigate = useNavigate();
@@ -42,8 +56,8 @@ const Packages = () => {
const [columnConfig, setColumnConfig] = useState(() => {
const defaultConfig = [
{ id: "name", label: "Package", visible: true, order: 0 },
{ id: "affectedHosts", label: "Affected Hosts", visible: true, order: 1 },
{ id: "priority", label: "Priority", visible: true, order: 2 },
{ id: "packageHosts", label: "Installed On", visible: true, order: 1 },
{ id: "status", label: "Status", visible: true, order: 2 },
{ id: "latestVersion", label: "Latest Version", visible: true, order: 3 },
];
@@ -65,10 +79,10 @@ const Packages = () => {
localStorage.setItem("packages-column-config", JSON.stringify(newConfig));
};
// Handle affected hosts click
const handleAffectedHostsClick = (pkg) => {
const affectedHosts = pkg.affectedHosts || [];
const hostIds = affectedHosts.map((host) => host.hostId);
// Handle hosts click (view hosts where package is installed)
const handlePackageHostsClick = (pkg) => {
const packageHosts = pkg.packageHosts || [];
const hostIds = packageHosts.map((host) => host.hostId);
// Create URL with selected hosts and filter
const params = new URLSearchParams();
@@ -86,27 +100,43 @@ const Packages = () => {
// For outdated packages, we want to show all packages that need updates
// This is the default behavior, so we don't need to change filters
setCategoryFilter("all");
setSecurityFilter("all");
setUpdateStatusFilter("needs-updates");
} else if (filter === "security") {
// For security updates, filter to show only security updates
setSecurityFilter("security");
setUpdateStatusFilter("security-updates");
setCategoryFilter("all");
}
}, [searchParams]);
const {
data: packages,
data: packagesResponse,
isLoading,
error,
refetch,
isFetching,
} = useQuery({
queryKey: ["packages"],
queryFn: () => dashboardAPI.getPackages().then((res) => res.data),
queryFn: () => packagesAPI.getAll({ limit: 1000 }).then((res) => res.data),
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus
});
// Extract packages from the response and normalise the data structure
const packages = useMemo(() => {
if (!packagesResponse?.packages) return [];
return packagesResponse.packages.map((pkg) => ({
...pkg,
// Normalise field names to match the frontend expectations
packageHostsCount: pkg.packageHostsCount || pkg.stats?.totalInstalls || 0,
latestVersion: pkg.latest_version || pkg.latestVersion || "Unknown",
isUpdatable: (pkg.stats?.updatesNeeded || 0) > 0,
isSecurityUpdate: (pkg.stats?.securityUpdates || 0) > 0,
// Ensure we have hosts array (for packages, this contains all hosts where the package is installed)
packageHosts: pkg.packageHosts || [],
}));
}, [packagesResponse]);
// Fetch hosts data to get total packages count
const { data: hosts } = useQuery({
queryKey: ["hosts"],
@@ -128,17 +158,30 @@ const Packages = () => {
const matchesCategory =
categoryFilter === "all" || pkg.category === categoryFilter;
const matchesSecurity =
securityFilter === "all" ||
(securityFilter === "security" && pkg.isSecurityUpdate) ||
(securityFilter === "regular" && !pkg.isSecurityUpdate);
const matchesUpdateStatus =
updateStatusFilter === "all-packages" ||
updateStatusFilter === "needs-updates" ||
(updateStatusFilter === "security-updates" && pkg.isSecurityUpdate) ||
(updateStatusFilter === "regular-updates" && !pkg.isSecurityUpdate);
const affectedHosts = pkg.affectedHosts || [];
// For "all-packages", we don't filter by update status
// For other filters, we only show packages that need updates
const matchesUpdateNeeded =
updateStatusFilter === "all-packages" ||
(pkg.stats?.updatesNeeded || 0) > 0;
const packageHosts = pkg.packageHosts || [];
const matchesHost =
hostFilter === "all" ||
affectedHosts.some((host) => host.hostId === hostFilter);
packageHosts.some((host) => host.hostId === hostFilter);
return matchesSearch && matchesCategory && matchesSecurity && matchesHost;
return (
matchesSearch &&
matchesCategory &&
matchesUpdateStatus &&
matchesUpdateNeeded &&
matchesHost
);
});
// Sorting
@@ -154,14 +197,38 @@ const Packages = () => {
aValue = a.latestVersion?.toLowerCase() || "";
bValue = b.latestVersion?.toLowerCase() || "";
break;
case "affectedHosts":
aValue = a.affectedHostsCount || a.affectedHosts?.length || 0;
bValue = b.affectedHostsCount || b.affectedHosts?.length || 0;
case "packageHosts":
aValue = a.packageHostsCount || a.packageHosts?.length || 0;
bValue = b.packageHostsCount || b.packageHosts?.length || 0;
break;
case "priority":
aValue = a.isSecurityUpdate ? 0 : 1; // Security updates first
bValue = b.isSecurityUpdate ? 0 : 1;
case "status": {
// Handle sorting for the three status states: Up to Date, Update Available, Security Update Available
const aNeedsUpdates = (a.stats?.updatesNeeded || 0) > 0;
const bNeedsUpdates = (b.stats?.updatesNeeded || 0) > 0;
// Define priority order: Security Update (0) > Regular Update (1) > Up to Date (2)
let aPriority, bPriority;
if (!aNeedsUpdates) {
aPriority = 2; // Up to Date
} else if (a.isSecurityUpdate) {
aPriority = 0; // Security Update
} else {
aPriority = 1; // Regular Update
}
if (!bNeedsUpdates) {
bPriority = 2; // Up to Date
} else if (b.isSecurityUpdate) {
bPriority = 0; // Security Update
} else {
bPriority = 1; // Regular Update
}
aValue = aPriority;
bValue = bPriority;
break;
}
default:
aValue = a.name?.toLowerCase() || "";
bValue = b.name?.toLowerCase() || "";
@@ -177,12 +244,33 @@ const Packages = () => {
packages,
searchTerm,
categoryFilter,
securityFilter,
updateStatusFilter,
sortField,
sortDirection,
hostFilter,
]);
// Calculate pagination
const totalPages = Math.ceil(filteredAndSortedPackages.length / pageSize);
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedPackages = filteredAndSortedPackages.slice(
startIndex,
endIndex,
);
// Reset to first page when filters or page size change
// biome-ignore lint/correctness/useExhaustiveDependencies: We want this effect to run when filter values or page size change to reset pagination
useEffect(() => {
setCurrentPage(1);
}, [searchTerm, categoryFilter, updateStatusFilter, hostFilter, pageSize]);
// Function to handle page size change and save to localStorage
const handlePageSizeChange = (newPageSize) => {
setPageSize(newPageSize);
localStorage.setItem("packages-page-size", newPageSize.toString());
};
// Get visible columns in order
const visibleColumns = columnConfig
.filter((col) => col.visible)
@@ -231,8 +319,8 @@ const Packages = () => {
const resetColumns = () => {
const defaultConfig = [
{ id: "name", label: "Package", visible: true, order: 0 },
{ id: "affectedHosts", label: "Affected Hosts", visible: true, order: 1 },
{ id: "priority", label: "Priority", visible: true, order: 2 },
{ id: "packageHosts", label: "Installed On", visible: true, order: 1 },
{ id: "status", label: "Status", visible: true, order: 2 },
{ id: "latestVersion", label: "Latest Version", visible: true, order: 3 },
];
updateColumnConfig(defaultConfig);
@@ -243,10 +331,14 @@ const Packages = () => {
switch (column.id) {
case "name":
return (
<div className="flex items-center">
<Package className="h-5 w-5 text-secondary-400 mr-3" />
<div>
<div className="text-sm font-medium text-secondary-900 dark:text-white">
<button
type="button"
onClick={() => navigate(`/packages/${pkg.id}`)}
className="flex items-center text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-2 -m-2 transition-colors group w-full"
>
<Package className="h-5 w-5 text-secondary-400 mr-3 flex-shrink-0" />
<div className="flex-1">
<div className="text-sm font-medium text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400">
{pkg.name}
</div>
{pkg.description && (
@@ -260,33 +352,58 @@ const Packages = () => {
</div>
)}
</div>
</div>
</button>
);
case "affectedHosts": {
const affectedHostsCount =
pkg.affectedHostsCount || pkg.affectedHosts?.length || 0;
case "packageHosts": {
// Show total number of hosts where this package is installed
const installedHostsCount =
pkg.packageHostsCount ||
pkg.stats?.totalInstalls ||
pkg.packageHosts?.length ||
0;
// For packages that need updates, show how many need updates
const hostsNeedingUpdates = pkg.stats?.updatesNeeded || 0;
const displayText =
hostsNeedingUpdates > 0 && hostsNeedingUpdates < installedHostsCount
? `${hostsNeedingUpdates}/${installedHostsCount} hosts`
: `${installedHostsCount} host${installedHostsCount !== 1 ? "s" : ""}`;
const titleText =
hostsNeedingUpdates > 0 && hostsNeedingUpdates < installedHostsCount
? `${hostsNeedingUpdates} of ${installedHostsCount} hosts need updates`
: `Installed on ${installedHostsCount} host${installedHostsCount !== 1 ? "s" : ""}`;
return (
<button
type="button"
onClick={() => handleAffectedHostsClick(pkg)}
onClick={() => handlePackageHostsClick(pkg)}
className="text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-1 -m-1 transition-colors group"
title={`Click to view all ${affectedHostsCount} affected hosts`}
title={titleText}
>
<div className="text-sm text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400">
{affectedHostsCount} host{affectedHostsCount !== 1 ? "s" : ""}
{displayText}
</div>
</button>
);
}
case "priority":
case "status": {
// Check if this package needs updates
const needsUpdates = (pkg.stats?.updatesNeeded || 0) > 0;
if (!needsUpdates) {
return <span className="badge-success">Up to Date</span>;
}
return pkg.isSecurityUpdate ? (
<span className="badge-danger flex items-center gap-1">
<span className="badge-danger">
<Shield className="h-3 w-3" />
Security Update
Security Update Available
</span>
) : (
<span className="badge-warning">Regular Update</span>
<span className="badge-warning">Update Available</span>
);
}
case "latestVersion":
return (
<div
@@ -305,28 +422,30 @@ const Packages = () => {
const categories =
[...new Set(packages?.map((pkg) => pkg.category).filter(Boolean))] || [];
// Calculate unique affected hosts
const uniqueAffectedHosts = new Set();
// Calculate unique package hosts
const uniquePackageHosts = new Set();
packages?.forEach((pkg) => {
const affectedHosts = pkg.affectedHosts || [];
affectedHosts.forEach((host) => {
uniqueAffectedHosts.add(host.hostId);
});
// Only count hosts for packages that need updates
if ((pkg.stats?.updatesNeeded || 0) > 0) {
const packageHosts = pkg.packageHosts || [];
packageHosts.forEach((host) => {
uniquePackageHosts.add(host.hostId);
});
}
});
const uniqueAffectedHostsCount = uniqueAffectedHosts.size;
const uniquePackageHostsCount = uniquePackageHosts.size;
// Calculate total packages across all hosts (including up-to-date ones)
const totalPackagesCount =
hosts?.reduce((total, host) => {
return total + (host.totalPackagesCount || 0);
}, 0) || 0;
// Calculate total packages available
const totalPackagesCount = packages?.length || 0;
// Calculate outdated packages (packages that need updates)
const outdatedPackagesCount = packages?.length || 0;
// Calculate outdated packages
const outdatedPackagesCount =
packages?.filter((pkg) => (pkg.stats?.updatesNeeded || 0) > 0).length || 0;
// Calculate security updates
const securityUpdatesCount =
packages?.filter((pkg) => pkg.isSecurityUpdate).length || 0;
packages?.filter((pkg) => (pkg.stats?.securityUpdates || 0) > 0).length ||
0;
if (isLoading) {
return (
@@ -429,7 +548,7 @@ const Packages = () => {
Hosts Pending Updates
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{uniqueAffectedHostsCount}
{uniquePackageHostsCount}
</p>
</div>
</div>
@@ -490,16 +609,21 @@ const Packages = () => {
</select>
</div>
{/* Security Filter */}
{/* Update Status Filter */}
<div className="sm:w-48">
<select
value={securityFilter}
onChange={(e) => setSecurityFilter(e.target.value)}
value={updateStatusFilter}
onChange={(e) => setUpdateStatusFilter(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All Updates</option>
<option value="security">Security Only</option>
<option value="regular">Regular Only</option>
<option value="all-packages">All Packages</option>
<option value="needs-updates">
Packages Needing Updates
</option>
<option value="security-updates">
Security Updates Only
</option>
<option value="regular-updates">Regular Updates Only</option>
</select>
</div>
@@ -539,12 +663,13 @@ const Packages = () => {
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
{packages?.length === 0
? "No packages need updates"
? "No packages found"
: "No packages match your filters"}
</p>
{packages?.length === 0 && (
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
All packages are up to date across all hosts
Packages will appear here once hosts start reporting their
installed packages
</p>
)}
</div>
@@ -571,7 +696,7 @@ const Packages = () => {
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredAndSortedPackages.map((pkg) => (
{paginatedPackages.map((pkg) => (
<tr
key={pkg.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
@@ -591,6 +716,57 @@ const Packages = () => {
</div>
)}
</div>
{/* Pagination Controls */}
{filteredAndSortedPackages.length > 0 && (
<div className="flex items-center justify-between px-6 py-3 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-secondary-700 dark:text-secondary-300">
Rows per page:
</span>
<select
value={pageSize}
onChange={(e) =>
handlePageSizeChange(Number(e.target.value))
}
className="text-sm border border-secondary-300 dark:border-secondary-600 rounded px-2 py-1 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
</div>
<span className="text-sm text-secondary-700 dark:text-secondary-300">
{startIndex + 1}-
{Math.min(endIndex, filteredAndSortedPackages.length)} of{" "}
{filteredAndSortedPackages.length}
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
className="p-1 rounded hover:bg-secondary-100 dark:hover:bg-secondary-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="text-sm text-secondary-700 dark:text-secondary-300">
Page {currentPage} of {totalPages}
</span>
<button
type="button"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
className="p-1 rounded hover:bg-secondary-100 dark:hover:bg-secondary-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
)}
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowDown,
@@ -7,7 +7,6 @@ import {
Check,
Columns,
Database,
Eye,
GripVertical,
Lock,
RefreshCw,
@@ -15,21 +14,24 @@ import {
Server,
Shield,
ShieldCheck,
Trash2,
Unlock,
Users,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { repositoryAPI } from "../utils/api";
const Repositories = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState("");
const [filterType, setFilterType] = useState("all"); // all, secure, insecure
const [filterStatus, setFilterStatus] = useState("all"); // all, active, inactive
const [sortField, setSortField] = useState("name");
const [sortDirection, setSortDirection] = useState("asc");
const [showColumnSettings, setShowColumnSettings] = useState(false);
const [deleteModalData, setDeleteModalData] = useState(null);
// Column configuration
const [columnConfig, setColumnConfig] = useState(() => {
@@ -80,6 +82,15 @@ const Repositories = () => {
queryFn: () => repositoryAPI.getStats().then((res) => res.data),
});
// Delete repository mutation
const deleteRepositoryMutation = useMutation({
mutationFn: (repositoryId) => repositoryAPI.delete(repositoryId),
onSuccess: () => {
queryClient.invalidateQueries(["repositories"]);
queryClient.invalidateQueries(["repository-stats"]);
},
});
// Get visible columns in order
const visibleColumns = columnConfig
.filter((col) => col.visible)
@@ -138,6 +149,32 @@ const Repositories = () => {
updateColumnConfig(defaultConfig);
};
const handleDeleteRepository = (repo, e) => {
e.preventDefault();
e.stopPropagation();
setDeleteModalData({
id: repo.id,
name: repo.name,
hostCount: repo.hostCount || 0,
});
};
const handleRowClick = (repo) => {
navigate(`/repositories/${repo.id}`);
};
const confirmDelete = () => {
if (deleteModalData) {
deleteRepositoryMutation.mutate(deleteModalData.id);
setDeleteModalData(null);
}
};
const cancelDelete = () => {
setDeleteModalData(null);
};
// Filter and sort repositories
const filteredAndSortedRepositories = useMemo(() => {
if (!repositories) return [];
@@ -225,6 +262,56 @@ const Repositories = () => {
return (
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
{/* Delete Confirmation Modal */}
{deleteModalData && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex items-center mb-4">
<AlertTriangle className="h-6 w-6 text-red-500 mr-3" />
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Delete Repository
</h3>
</div>
<div className="mb-6">
<p className="text-secondary-700 dark:text-secondary-300 mb-2">
Are you sure you want to delete{" "}
<strong>"{deleteModalData.name}"</strong>?
</p>
{deleteModalData.hostCount > 0 && (
<p className="text-amber-600 dark:text-amber-400 text-sm">
This repository is currently assigned to{" "}
{deleteModalData.hostCount} host
{deleteModalData.hostCount !== 1 ? "s" : ""}.
</p>
)}
<p className="text-red-600 dark:text-red-400 text-sm mt-2">
This action cannot be undone.
</p>
</div>
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={cancelDelete}
className="px-4 py-2 text-secondary-600 dark:text-secondary-400 hover:text-secondary-800 dark:hover:text-secondary-200 transition-colors"
disabled={deleteRepositoryMutation.isPending}
>
Cancel
</button>
<button
type="button"
onClick={confirmDelete}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={deleteRepositoryMutation.isPending}
>
{deleteRepositoryMutation.isPending
? "Deleting..."
: "Delete Repository"}
</button>
</div>
</div>
</div>
)}
{/* Page Header */}
<div className="flex items-center justify-between mb-6">
<div>
@@ -415,7 +502,8 @@ const Repositories = () => {
{filteredAndSortedRepositories.map((repo) => (
<tr
key={repo.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors cursor-pointer"
onClick={() => handleRowClick(repo)}
>
{visibleColumns.map((column) => (
<td
@@ -513,19 +601,23 @@ const Repositories = () => {
case "hostCount":
return (
<div className="flex items-center justify-center gap-1 text-sm text-secondary-900 dark:text-white">
<Users className="h-4 w-4" />
<span>{repo.host_count}</span>
<Server className="h-4 w-4" />
<span>{repo.hostCount}</span>
</div>
);
case "actions":
return (
<Link
to={`/repositories/${repo.id}`}
className="text-primary-600 hover:text-primary-900 flex items-center gap-1"
>
View
<Eye className="h-3 w-3" />
</Link>
<div className="flex items-center justify-center">
<button
type="button"
onClick={(e) => handleDeleteRepository(repo, e)}
className="text-orange-600 hover:text-red-900 dark:text-orange-600 dark:hover:text-red-400 flex items-center gap-1"
disabled={deleteRepositoryMutation.isPending}
title="Delete repository"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
);
default:
return null;

View File

@@ -6,17 +6,18 @@ import {
Database,
Globe,
Lock,
Search,
Server,
Shield,
ShieldOff,
Trash2,
Unlock,
Users,
} from "lucide-react";
import { useId, useState } from "react";
import { useId, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { repositoryAPI } from "../utils/api";
import { Link, useNavigate, useParams } from "react-router-dom";
import { formatRelativeTime, repositoryAPI } from "../utils/api";
const RepositoryDetail = () => {
const isActiveId = useId();
@@ -24,9 +25,14 @@ const RepositoryDetail = () => {
const priorityId = useId();
const descriptionId = useId();
const { repositoryId } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [editMode, setEditMode] = useState(false);
const [formData, setFormData] = useState({});
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [showDeleteModal, setShowDeleteModal] = useState(false);
// Fetch repository details
const {
@@ -39,6 +45,49 @@ const RepositoryDetail = () => {
enabled: !!repositoryId,
});
const hosts = repository?.host_repositories || [];
// Filter and paginate hosts
const filteredAndPaginatedHosts = useMemo(() => {
let filtered = hosts;
if (searchTerm) {
filtered = hosts.filter(
(hostRepo) =>
hostRepo.hosts.friendly_name
?.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
hostRepo.hosts.hostname
?.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
hostRepo.hosts.ip?.toLowerCase().includes(searchTerm.toLowerCase()),
);
}
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
return filtered.slice(startIndex, endIndex);
}, [hosts, searchTerm, currentPage, pageSize]);
const totalPages = Math.ceil(
(searchTerm
? hosts.filter(
(hostRepo) =>
hostRepo.hosts.friendly_name
?.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
hostRepo.hosts.hostname
?.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
hostRepo.hosts.ip?.toLowerCase().includes(searchTerm.toLowerCase()),
).length
: hosts.length) / pageSize,
);
const handleHostClick = (hostId) => {
navigate(`/hosts/${hostId}`);
};
// Update repository mutation
const updateRepositoryMutation = useMutation({
mutationFn: (data) => repositoryAPI.update(repositoryId, data),
@@ -49,6 +98,15 @@ const RepositoryDetail = () => {
},
});
// Delete repository mutation
const deleteRepositoryMutation = useMutation({
mutationFn: () => repositoryAPI.delete(repositoryId),
onSuccess: () => {
queryClient.invalidateQueries(["repositories"]);
navigate("/repositories");
},
});
const handleEdit = () => {
setFormData({
name: repository.name,
@@ -68,6 +126,19 @@ const RepositoryDetail = () => {
setFormData({});
};
const handleDelete = () => {
setShowDeleteModal(true);
};
const confirmDelete = () => {
deleteRepositoryMutation.mutate();
setShowDeleteModal(false);
};
const cancelDelete = () => {
setShowDeleteModal(false);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
@@ -127,6 +198,56 @@ const RepositoryDetail = () => {
return (
<div className="space-y-6">
{/* Delete Confirmation Modal */}
{showDeleteModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex items-center mb-4">
<AlertTriangle className="h-6 w-6 text-red-500 mr-3" />
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Delete Repository
</h3>
</div>
<div className="mb-6">
<p className="text-secondary-700 dark:text-secondary-300 mb-2">
Are you sure you want to delete{" "}
<strong>"{repository?.name}"</strong>?
</p>
{repository?.host_repositories?.length > 0 && (
<p className="text-amber-600 dark:text-amber-400 text-sm">
This repository is currently assigned to{" "}
{repository.host_repositories.length} host
{repository.host_repositories.length !== 1 ? "s" : ""}.
</p>
)}
<p className="text-red-600 dark:text-red-400 text-sm mt-2">
This action cannot be undone.
</p>
</div>
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={cancelDelete}
className="px-4 py-2 text-secondary-600 dark:text-secondary-400 hover:text-secondary-800 dark:hover:text-secondary-200 transition-colors"
disabled={deleteRepositoryMutation.isPending}
>
Cancel
</button>
<button
type="button"
onClick={confirmDelete}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={deleteRepositoryMutation.isPending}
>
{deleteRepositoryMutation.isPending
? "Deleting..."
: "Delete Repository"}
</button>
</div>
</div>
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
@@ -157,9 +278,6 @@ const RepositoryDetail = () => {
{repository.is_active ? "Active" : "Inactive"}
</span>
</div>
<p className="text-secondary-500 dark:text-secondary-300 mt-1">
Repository configuration and host assignments
</p>
</div>
</div>
<div className="flex items-center gap-2">
@@ -185,15 +303,30 @@ const RepositoryDetail = () => {
</button>
</>
) : (
<button type="button" onClick={handleEdit} className="btn-primary">
Edit Repository
</button>
<>
<button
type="button"
onClick={handleDelete}
className="btn-outline border-red-200 text-red-600 hover:bg-red-50 hover:border-red-300 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20 dark:hover:border-red-700 flex items-center gap-2"
disabled={deleteRepositoryMutation.isPending}
>
<Trash2 className="h-4 w-4" />
{deleteRepositoryMutation.isPending ? "Deleting..." : "Delete"}
</button>
<button
type="button"
onClick={handleEdit}
className="btn-primary"
>
Edit Repository
</button>
</>
)}
</div>
</div>
{/* Repository Information */}
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
<div className="card">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
Repository Information
@@ -369,80 +502,159 @@ const RepositoryDetail = () => {
</div>
{/* Hosts Using This Repository */}
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white flex items-center gap-2">
<Users className="h-5 w-5" />
Hosts Using This Repository (
{repository.host_repositories?.length || 0})
</h2>
</div>
{!repository.host_repositories ||
repository.host_repositories.length === 0 ? (
<div className="px-6 py-12 text-center">
<Server className="mx-auto h-12 w-12 text-secondary-400" />
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">
No hosts using this repository
</h3>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
This repository hasn't been reported by any hosts yet.
</p>
<div className="card">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-primary-600" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Hosts Using This Repository ({hosts.length})
</h3>
</div>
</div>
) : (
<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
{repository.host_repositories.map((hostRepo) => (
<div
key={hostRepo.id}
className="px-6 py-4 hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className={`w-3 h-3 rounded-full ${
hostRepo.hosts.status === "active"
? "bg-green-500"
: hostRepo.hosts.status === "pending"
? "bg-yellow-500"
: "bg-red-500"
}`}
/>
<div>
<Link
to={`/hosts/${hostRepo.hosts.id}`}
className="text-primary-600 hover:text-primary-700 font-medium"
>
{hostRepo.hosts.friendly_name}
</Link>
<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400 mt-1">
<span>IP: {hostRepo.hosts.ip}</span>
<span>
OS: {hostRepo.hosts.os_type}{" "}
{hostRepo.hosts.os_version}
</span>
<span>
Last Update:{" "}
{new Date(
hostRepo.hosts.last_update,
).toLocaleDateString()}
</span>
</div>
</div>
{/* Search */}
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400" />
<input
type="text"
placeholder="Search hosts..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
/>
</div>
</div>
<div className="overflow-x-auto">
{filteredAndPaginatedHosts.length === 0 ? (
<div className="text-center py-8">
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
{searchTerm
? "No hosts match your search"
: "This repository hasn't been reported by any hosts yet."}
</p>
</div>
) : (
<>
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Host
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Operating System
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Last Checked
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Last Update
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredAndPaginatedHosts.map((hostRepo) => (
<tr
key={hostRepo.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 cursor-pointer transition-colors"
onClick={() => handleHostClick(hostRepo.hosts.id)}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div
className={`w-2 h-2 rounded-full mr-3 ${
hostRepo.hosts.status === "active"
? "bg-success-500"
: hostRepo.hosts.status === "pending"
? "bg-warning-500"
: "bg-danger-500"
}`}
/>
<Server className="h-5 w-5 text-secondary-400 mr-3" />
<div>
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{hostRepo.hosts.friendly_name ||
hostRepo.hosts.hostname}
</div>
{hostRepo.hosts.friendly_name &&
hostRepo.hosts.hostname && (
<div className="text-sm text-secondary-500 dark:text-secondary-300">
{hostRepo.hosts.hostname}
</div>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
{hostRepo.hosts.os_type} {hostRepo.hosts.os_version}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-300">
{hostRepo.last_checked
? formatRelativeTime(hostRepo.last_checked)
: "Never"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-300">
{hostRepo.hosts.last_update
? formatRelativeTime(hostRepo.hosts.last_update)
: "Never"}
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
{totalPages > 1 && (
<div className="px-6 py-3 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-600 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-secondary-700 dark:text-secondary-300">
Rows per page:
</span>
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value));
setCurrentPage(1);
}}
className="text-sm border border-secondary-300 dark:border-secondary-600 rounded px-2 py-1 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
<div className="flex items-center gap-4">
<div className="text-center">
<div className="text-xs text-secondary-500 dark:text-secondary-400">
Last Checked
</div>
<div className="text-sm text-secondary-900 dark:text-white">
{new Date(hostRepo.last_checked).toLocaleDateString()}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
className="px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-secondary-50 dark:hover:bg-secondary-700"
>
Previous
</button>
<span className="text-sm text-secondary-700 dark:text-secondary-300">
Page {currentPage} of {totalPages}
</span>
<button
type="button"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-secondary-50 dark:hover:bg-secondary-700"
>
Next
</button>
</div>
</div>
</div>
))}
</div>
)}
)}
</>
)}
</div>
</div>
</div>
);

View File

@@ -5,11 +5,13 @@ import {
Clock,
Code,
Download,
Image,
Plus,
Save,
Server,
Settings as SettingsIcon,
Shield,
Upload,
X,
} from "lucide-react";
@@ -80,6 +82,15 @@ const Settings = () => {
});
const [showUploadModal, setShowUploadModal] = useState(false);
// Logo management state
const [logoUploadState, setLogoUploadState] = useState({
dark: { uploading: false, error: null },
light: { uploading: false, error: null },
favicon: { uploading: false, error: null },
});
const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
const [selectedLogoType, setSelectedLogoType] = useState("dark");
// Version checking state
const [versionInfo, setVersionInfo] = useState({
currentVersion: null, // Will be loaded from API
@@ -192,6 +203,37 @@ const Settings = () => {
},
});
// Logo upload mutation
const uploadLogoMutation = useMutation({
mutationFn: ({ logoType, fileContent, fileName }) =>
fetch("/api/v1/settings/logos/upload", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: JSON.stringify({ logoType, fileContent, fileName }),
}).then((res) => res.json()),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries(["settings"]);
setLogoUploadState((prev) => ({
...prev,
[variables.logoType]: { uploading: false, error: null },
}));
setShowLogoUploadModal(false);
},
onError: (error, variables) => {
console.error("Upload logo error:", error);
setLogoUploadState((prev) => ({
...prev,
[variables.logoType]: {
uploading: false,
error: error.message || "Failed to upload logo",
},
}));
},
});
// Load current version on component mount
useEffect(() => {
const loadCurrentVersion = async () => {
@@ -556,6 +598,181 @@ const Settings = () => {
</p>
</div>
{/* Logo Management Section */}
<div className="mt-6 pt-6 border-t border-secondary-200 dark:border-secondary-600">
<div className="flex items-center mb-4">
<Image className="h-5 w-5 text-primary-600 mr-2" />
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Logo & Branding
</h3>
</div>
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-4">
Customize your PatchMon installation with custom logos and
favicon.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Dark Logo */}
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3">
Dark Logo
</h4>
{settings?.logo_dark && (
<div className="flex items-center justify-center p-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-3">
<img
src={settings.logo_dark}
alt="Dark Logo"
className="max-h-12 max-w-full object-contain"
onError={(e) => {
e.target.style.display = "none";
}}
/>
</div>
)}
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-3 truncate">
{settings?.logo_dark
? settings.logo_dark.split("/").pop()
: "Default"}
</p>
<button
type="button"
onClick={() => {
setSelectedLogoType("dark");
setShowLogoUploadModal(true);
}}
disabled={logoUploadState.dark.uploading}
className="w-full btn-outline flex items-center justify-center gap-2 text-sm py-2"
>
{logoUploadState.dark.uploading ? (
<>
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-current"></div>
Uploading...
</>
) : (
<>
<Upload className="h-3 w-3" />
Upload
</>
)}
</button>
{logoUploadState.dark.error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
{logoUploadState.dark.error}
</p>
)}
</div>
{/* Light Logo */}
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3">
Light Logo
</h4>
{settings?.logo_light && (
<div className="flex items-center justify-center p-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-3">
<img
src={settings.logo_light}
alt="Light Logo"
className="max-h-12 max-w-full object-contain"
onError={(e) => {
e.target.style.display = "none";
}}
/>
</div>
)}
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-3 truncate">
{settings?.logo_light
? settings.logo_light.split("/").pop()
: "Default"}
</p>
<button
type="button"
onClick={() => {
setSelectedLogoType("light");
setShowLogoUploadModal(true);
}}
disabled={logoUploadState.light.uploading}
className="w-full btn-outline flex items-center justify-center gap-2 text-sm py-2"
>
{logoUploadState.light.uploading ? (
<>
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-current"></div>
Uploading...
</>
) : (
<>
<Upload className="h-3 w-3" />
Upload
</>
)}
</button>
{logoUploadState.light.error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
{logoUploadState.light.error}
</p>
)}
</div>
{/* Favicon */}
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3">
Favicon
</h4>
{settings?.favicon && (
<div className="flex items-center justify-center p-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-3">
<img
src={settings.favicon}
alt="Favicon"
className="h-8 w-8 object-contain"
onError={(e) => {
e.target.style.display = "none";
}}
/>
</div>
)}
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-3 truncate">
{settings?.favicon
? settings.favicon.split("/").pop()
: "Default"}
</p>
<button
type="button"
onClick={() => {
setSelectedLogoType("favicon");
setShowLogoUploadModal(true);
}}
disabled={logoUploadState.favicon.uploading}
className="w-full btn-outline flex items-center justify-center gap-2 text-sm py-2"
>
{logoUploadState.favicon.uploading ? (
<>
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-current"></div>
Uploading...
</>
) : (
<>
<Upload className="h-3 w-3" />
Upload
</>
)}
</button>
{logoUploadState.favicon.error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
{logoUploadState.favicon.error}
</p>
)}
</div>
</div>
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-md">
<p className="text-xs text-blue-700 dark:text-blue-300">
<strong>Supported formats:</strong> PNG, JPG, SVG.{" "}
<strong>Max size:</strong> 5MB.
<strong> Recommended sizes:</strong> 200x60px for logos,
32x32px for favicon.
</p>
</div>
</div>
{/* Update Interval */}
<div>
<label
@@ -1319,6 +1536,18 @@ const Settings = () => {
error={uploadAgentMutation.error}
/>
)}
{/* Logo Upload Modal */}
{showLogoUploadModal && (
<LogoUploadModal
isOpen={showLogoUploadModal}
onClose={() => setShowLogoUploadModal(false)}
onSubmit={uploadLogoMutation.mutate}
isLoading={uploadLogoMutation.isPending}
error={uploadLogoMutation.error}
logoType={selectedLogoType}
/>
)}
</div>
);
};
@@ -1467,4 +1696,181 @@ const AgentUploadModal = ({ isOpen, onClose, onSubmit, isLoading, error }) => {
);
};
// Logo Upload Modal Component
const LogoUploadModal = ({
isOpen,
onClose,
onSubmit,
isLoading,
error,
logoType,
}) => {
const [selectedFile, setSelectedFile] = useState(null);
const [previewUrl, setPreviewUrl] = useState(null);
const [uploadError, setUploadError] = useState("");
const handleFileSelect = (e) => {
const file = e.target.files[0];
if (file) {
// Validate file type
const allowedTypes = [
"image/png",
"image/jpeg",
"image/jpg",
"image/svg+xml",
];
if (!allowedTypes.includes(file.type)) {
setUploadError("Please select a PNG, JPG, or SVG file");
return;
}
// Validate file size (5MB limit)
if (file.size > 5 * 1024 * 1024) {
setUploadError("File size must be less than 5MB");
return;
}
setSelectedFile(file);
setUploadError("");
// Create preview URL
const url = URL.createObjectURL(file);
setPreviewUrl(url);
}
};
const handleSubmit = (e) => {
e.preventDefault();
setUploadError("");
if (!selectedFile) {
setUploadError("Please select a file");
return;
}
// Convert file to base64
const reader = new FileReader();
reader.onload = (event) => {
const base64 = event.target.result;
onSubmit({
logoType,
fileContent: base64,
fileName: selectedFile.name,
});
};
reader.readAsDataURL(selectedFile);
};
const handleClose = () => {
setSelectedFile(null);
setPreviewUrl(null);
setUploadError("");
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Upload{" "}
{logoType === "favicon"
? "Favicon"
: `${logoType.charAt(0).toUpperCase() + logoType.slice(1)} Logo`}
</h3>
<button
type="button"
onClick={handleClose}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="px-6 py-4">
<div className="space-y-4">
<div>
<label className="block">
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
Select File
</span>
<input
type="file"
accept="image/png,image/jpeg,image/jpg,image/svg+xml"
onChange={handleFileSelect}
className="block w-full text-sm text-secondary-500 dark:text-secondary-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900 dark:file:text-primary-200"
/>
</label>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Supported formats: PNG, JPG, SVG. Max size: 5MB.
{logoType === "favicon"
? " Recommended: 32x32px SVG."
: " Recommended: 200x60px."}
</p>
</div>
{previewUrl && (
<div>
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
Preview
</div>
<div className="flex items-center justify-center p-4 bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-600">
<img
src={previewUrl}
alt="Preview"
className={`object-contain ${
logoType === "favicon" ? "h-8 w-8" : "max-h-16 max-w-full"
}`}
/>
</div>
</div>
)}
{(uploadError || error) && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
<p className="text-sm text-red-800 dark:text-red-200">
{uploadError ||
error?.response?.data?.error ||
error?.message}
</p>
</div>
)}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3">
<div className="flex">
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mr-2 mt-0.5" />
<div className="text-sm text-yellow-800 dark:text-yellow-200">
<p className="font-medium">Important:</p>
<ul className="mt-1 list-disc list-inside space-y-1">
<li>This will replace the current {logoType} logo</li>
<li>A backup will be created automatically</li>
<li>The change will be applied immediately</li>
</ul>
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button type="button" onClick={handleClose} className="btn-outline">
Cancel
</button>
<button
type="submit"
disabled={isLoading || !selectedFile}
className="btn-primary"
>
{isLoading ? "Uploading..." : "Upload Logo"}
</button>
</div>
</form>
</div>
</div>
);
};
export default Settings;

View File

@@ -1,47 +1,751 @@
import { Plug } from "lucide-react";
import {
AlertCircle,
CheckCircle,
Copy,
Eye,
EyeOff,
Plus,
Server,
Trash2,
X,
} from "lucide-react";
import { useEffect, useState } from "react";
import SettingsLayout from "../../components/SettingsLayout";
import api from "../../utils/api";
const Integrations = () => {
const [activeTab, setActiveTab] = useState("proxmox");
const [tokens, setTokens] = useState([]);
const [host_groups, setHostGroups] = useState([]);
const [loading, setLoading] = useState(true);
const [show_create_modal, setShowCreateModal] = useState(false);
const [new_token, setNewToken] = useState(null);
const [show_secret, setShowSecret] = useState(false);
const [server_url, setServerUrl] = useState("");
const [force_proxmox_install, setForceProxmoxInstall] = useState(false);
// Form state
const [form_data, setFormData] = useState({
token_name: "",
max_hosts_per_day: 100,
default_host_group_id: "",
allowed_ip_ranges: "",
expires_at: "",
});
const [copy_success, setCopySuccess] = useState({});
// Helper function to build Proxmox enrollment URL with optional force flag
const getProxmoxUrl = () => {
const baseUrl = `${server_url}/api/v1/auto-enrollment/proxmox-lxc?token_key=${new_token.token_key}&token_secret=${new_token.token_secret}`;
return force_proxmox_install ? `${baseUrl}&force=true` : baseUrl;
};
const handleTabChange = (tabName) => {
setActiveTab(tabName);
};
// biome-ignore lint/correctness/useExhaustiveDependencies: Only run on mount
useEffect(() => {
load_tokens();
load_host_groups();
load_server_url();
}, []);
const load_tokens = async () => {
try {
setLoading(true);
const response = await api.get("/auto-enrollment/tokens");
setTokens(response.data);
} catch (error) {
console.error("Failed to load tokens:", error);
} finally {
setLoading(false);
}
};
const load_host_groups = async () => {
try {
const response = await api.get("/host-groups");
setHostGroups(response.data);
} catch (error) {
console.error("Failed to load host groups:", error);
}
};
const load_server_url = async () => {
try {
const response = await api.get("/settings");
setServerUrl(response.data.server_url || window.location.origin);
} catch (error) {
console.error("Failed to load server URL:", error);
setServerUrl(window.location.origin);
}
};
const create_token = async (e) => {
e.preventDefault();
try {
const data = {
token_name: form_data.token_name,
max_hosts_per_day: Number.parseInt(form_data.max_hosts_per_day, 10),
allowed_ip_ranges: form_data.allowed_ip_ranges
? form_data.allowed_ip_ranges.split(",").map((ip) => ip.trim())
: [],
metadata: {
integration_type: "proxmox-lxc",
},
};
// Only add optional fields if they have values
if (form_data.default_host_group_id) {
data.default_host_group_id = form_data.default_host_group_id;
}
if (form_data.expires_at) {
data.expires_at = form_data.expires_at;
}
const response = await api.post("/auto-enrollment/tokens", data);
setNewToken(response.data.token);
setShowCreateModal(false);
load_tokens();
// Reset form
setFormData({
token_name: "",
max_hosts_per_day: 100,
default_host_group_id: "",
allowed_ip_ranges: "",
expires_at: "",
});
} catch (error) {
console.error("Failed to create token:", error);
const error_message = error.response?.data?.errors
? error.response.data.errors.map((e) => e.msg).join(", ")
: error.response?.data?.error || "Failed to create token";
alert(error_message);
}
};
const delete_token = async (id, name) => {
if (
!confirm(
`Are you sure you want to delete the token "${name}"? This action cannot be undone.`,
)
) {
return;
}
try {
await api.delete(`/auto-enrollment/tokens/${id}`);
load_tokens();
} catch (error) {
console.error("Failed to delete token:", error);
alert(error.response?.data?.error || "Failed to delete token");
}
};
const toggle_token_active = async (id, current_status) => {
try {
await api.patch(`/auto-enrollment/tokens/${id}`, {
is_active: !current_status,
});
load_tokens();
} catch (error) {
console.error("Failed to toggle token:", error);
alert(error.response?.data?.error || "Failed to toggle token");
}
};
const copy_to_clipboard = (text, key) => {
navigator.clipboard.writeText(text);
setCopySuccess({ ...copy_success, [key]: true });
setTimeout(() => {
setCopySuccess({ ...copy_success, [key]: false });
}, 2000);
};
const format_date = (date_string) => {
if (!date_string) return "Never";
return new Date(date_string).toLocaleString();
};
return (
<SettingsLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
Integrations
</h1>
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
Connect PatchMon to third-party services
</p>
</div>
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
Integrations
</h1>
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
Manage auto-enrollment tokens for Proxmox and other integrations
</p>
</div>
{/* Coming Soon Card */}
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
<div className="flex items-center gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-secondary-100 dark:bg-secondary-700 rounded-lg flex items-center justify-center">
<Plug className="h-6 w-6 text-secondary-700 dark:text-secondary-200" />
{/* Tabs Navigation */}
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg overflow-hidden">
<div className="border-b border-secondary-200 dark:border-secondary-600 flex">
<button
type="button"
onClick={() => handleTabChange("proxmox")}
className={`px-6 py-3 text-sm font-medium ${
activeTab === "proxmox"
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500 bg-primary-50 dark:bg-primary-900/20"
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
}`}
>
Proxmox LXC
</button>
{/* Future tabs can be added here */}
</div>
{/* Tab Content */}
<div className="p-6">
{/* Proxmox Tab */}
{activeTab === "proxmox" && (
<div className="space-y-6">
{/* Header with New Token Button */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900 rounded-lg flex items-center justify-center">
<Server className="h-5 w-5 text-primary-600 dark:text-primary-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Proxmox LXC Auto-Enrollment
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-400">
Automatically discover and enroll LXC containers from
Proxmox hosts
</p>
</div>
</div>
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
New Token
</button>
</div>
{/* Token List */}
{loading ? (
<div className="text-center py-8">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
</div>
) : tokens.length === 0 ? (
<div className="text-center py-8 text-secondary-600 dark:text-secondary-400">
<p>No auto-enrollment tokens created yet.</p>
<p className="text-sm mt-2">
Create a token to enable automatic host enrollment from
Proxmox.
</p>
</div>
) : (
<div className="space-y-3">
{tokens.map((token) => (
<div
key={token.id}
className="border border-secondary-200 dark:border-secondary-600 rounded-lg p-4 hover:border-primary-300 dark:hover:border-primary-700 transition-colors"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="font-medium text-secondary-900 dark:text-white">
{token.token_name}
</h4>
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Proxmox LXC
</span>
{token.is_active ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Active
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
Inactive
</span>
)}
</div>
<div className="mt-2 space-y-1 text-sm text-secondary-600 dark:text-secondary-400">
<div className="flex items-center gap-2">
<span className="font-mono text-xs bg-secondary-100 dark:bg-secondary-700 px-2 py-1 rounded">
{token.token_key}
</span>
<button
type="button"
onClick={() =>
copy_to_clipboard(
token.token_key,
`key-${token.id}`,
)
}
className="text-primary-600 hover:text-primary-700 dark:text-primary-400"
>
{copy_success[`key-${token.id}`] ? (
<CheckCircle className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
<p>
Usage: {token.hosts_created_today}/
{token.max_hosts_per_day} hosts today
</p>
{token.host_groups && (
<p>
Default Group:{" "}
<span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
style={{
backgroundColor: `${token.host_groups.color}20`,
color: token.host_groups.color,
}}
>
{token.host_groups.name}
</span>
</p>
)}
{token.allowed_ip_ranges?.length > 0 && (
<p>
Allowed IPs:{" "}
{token.allowed_ip_ranges.join(", ")}
</p>
)}
<p>Created: {format_date(token.created_at)}</p>
{token.last_used_at && (
<p>
Last Used: {format_date(token.last_used_at)}
</p>
)}
{token.expires_at && (
<p>
Expires: {format_date(token.expires_at)}
{new Date(token.expires_at) < new Date() && (
<span className="ml-2 text-red-600 dark:text-red-400">
(Expired)
</span>
)}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
toggle_token_active(token.id, token.is_active)
}
className={`px-3 py-1 text-sm rounded ${
token.is_active
? "bg-secondary-100 text-secondary-700 hover:bg-secondary-200 dark:bg-secondary-700 dark:text-secondary-300"
: "bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900 dark:text-green-300"
}`}
>
{token.is_active ? "Disable" : "Enable"}
</button>
<button
type="button"
onClick={() =>
delete_token(token.id, token.token_name)
}
className="text-red-600 hover:text-red-800 dark:text-red-400 p-2"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Documentation Section */}
<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6">
<h3 className="text-lg font-semibold text-primary-900 dark:text-primary-200 mb-3">
How to Use Auto-Enrollment
</h3>
<ol className="list-decimal list-inside space-y-2 text-sm text-primary-800 dark:text-primary-300">
<li>
Create a new auto-enrollment token using the button above
</li>
<li>
Copy the one-line installation command shown in the
success dialog
</li>
<li>SSH into your Proxmox host as root</li>
<li>
Paste and run the command - it will automatically discover
and enroll all running LXC containers
</li>
<li>View enrolled containers in the Hosts page</li>
</ol>
<div className="mt-4 p-3 bg-primary-100 dark:bg-primary-900/40 rounded border border-primary-200 dark:border-primary-700">
<p className="text-xs text-primary-800 dark:text-primary-300">
<strong>💡 Tip:</strong> You can run the same command
multiple times safely - already enrolled containers will
be automatically skipped.
</p>
</div>
</div>
</div>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Integrations Coming Soon
</h3>
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
We are building integrations for Slack, Discord, email, and
webhooks to streamline alerts and workflows.
</p>
<div className="mt-3">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
In Development
</span>
</div>
</div>
)}
</div>
</div>
</div>
{/* Create Token Modal */}
{show_create_modal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-secondary-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-secondary-900 dark:text-white">
Create Auto-Enrollment Token
</h2>
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-200"
>
<X className="h-6 w-6" />
</button>
</div>
<form onSubmit={create_token} className="space-y-4">
<label className="block">
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Token Name *
</span>
<input
type="text"
required
value={form_data.token_name}
onChange={(e) =>
setFormData({ ...form_data, token_name: e.target.value })
}
placeholder="e.g., Proxmox Production"
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
/>
</label>
<label className="block">
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Max Hosts Per Day
</span>
<input
type="number"
min="1"
max="1000"
value={form_data.max_hosts_per_day}
onChange={(e) =>
setFormData({
...form_data,
max_hosts_per_day: e.target.value,
})
}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Maximum number of hosts that can be enrolled per day using
this token
</p>
</label>
<label className="block">
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Default Host Group (Optional)
</span>
<select
value={form_data.default_host_group_id}
onChange={(e) =>
setFormData({
...form_data,
default_host_group_id: e.target.value,
})
}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
>
<option value="">No default group</option>
{host_groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Auto-enrolled hosts will be assigned to this group
</p>
</label>
<label className="block">
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Allowed IP Addresses (Optional)
</span>
<input
type="text"
value={form_data.allowed_ip_ranges}
onChange={(e) =>
setFormData({
...form_data,
allowed_ip_ranges: e.target.value,
})
}
placeholder="e.g., 192.168.1.100, 10.0.0.50"
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Comma-separated list of IP addresses allowed to use this
token
</p>
</label>
<label className="block">
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Expiration Date (Optional)
</span>
<input
type="datetime-local"
value={form_data.expires_at}
onChange={(e) =>
setFormData({ ...form_data, expires_at: e.target.value })
}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
/>
</label>
<div className="flex gap-3 pt-4">
<button
type="submit"
className="flex-1 btn-primary py-2 px-4 rounded-md"
>
Create Token
</button>
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="flex-1 bg-secondary-100 dark:bg-secondary-700 text-secondary-700 dark:text-secondary-300 py-2 px-4 rounded-md hover:bg-secondary-200 dark:hover:bg-secondary-600"
>
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* New Token Display Modal */}
{new_token && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-secondary-800 rounded-lg max-w-2xl w-full">
<div className="p-6">
<div className="flex items-start gap-3 mb-6">
<div className="flex-shrink-0">
<CheckCircle className="h-8 w-8 text-green-600 dark:text-green-400" />
</div>
<div>
<h2 className="text-xl font-bold text-secondary-900 dark:text-white">
Token Created Successfully
</h2>
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
Save these credentials now - the secret will not be shown
again!
</p>
</div>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-yellow-800 dark:text-yellow-200">
<strong>Important:</strong> Store the token secret securely.
You will not be able to view it again after closing this
dialog.
</p>
</div>
</div>
<div className="space-y-4">
<div>
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Token Name
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={new_token.token_name}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-sm"
/>
</div>
</div>
<div>
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Token Key
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={new_token.token_key}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-sm"
/>
<button
type="button"
onClick={() =>
copy_to_clipboard(new_token.token_key, "new-key")
}
className="btn-primary flex items-center gap-1 px-3 py-2"
>
{copy_success["new-key"] ? (
<>
<CheckCircle className="h-4 w-4" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy
</>
)}
</button>
</div>
</div>
<div>
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Token Secret
</div>
<div className="flex items-center gap-2">
<input
type={show_secret ? "text" : "password"}
value={new_token.token_secret}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-sm"
/>
<button
type="button"
onClick={() => setShowSecret(!show_secret)}
className="p-2 text-secondary-600 hover:text-secondary-800 dark:text-secondary-400 dark:hover:text-secondary-200"
>
{show_secret ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button>
<button
type="button"
onClick={() =>
copy_to_clipboard(new_token.token_secret, "new-secret")
}
className="btn-primary flex items-center gap-1 px-3 py-2"
>
{copy_success["new-secret"] ? (
<>
<CheckCircle className="h-4 w-4" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy
</>
)}
</button>
</div>
</div>
<div className="mt-6">
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
One-Line Installation Command
</div>
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-2">
Run this command on your Proxmox host to download and
execute the enrollment script:
</p>
{/* Force Install Toggle */}
<div className="mb-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={force_proxmox_install}
onChange={(e) =>
setForceProxmoxInstall(e.target.checked)
}
className="rounded border-secondary-300 dark:border-secondary-600 text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-400 dark:bg-secondary-700"
/>
<span className="text-secondary-800 dark:text-secondary-200">
Force install (bypass broken packages in containers)
</span>
</label>
<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-1">
Enable this if your LXC containers have broken packages
(CloudPanel, WHM, etc.) that block apt-get operations
</p>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={`curl -s "${getProxmoxUrl()}" | bash`}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
/>
<button
type="button"
onClick={() =>
copy_to_clipboard(
`curl -s "${getProxmoxUrl()}" | bash`,
"curl-command",
)
}
className="btn-primary flex items-center gap-1 px-3 py-2 whitespace-nowrap"
>
{copy_success["curl-command"] ? (
<>
<CheckCircle className="h-4 w-4" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy
</>
)}
</button>
</div>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-2">
💡 This command will automatically discover and enroll all
running LXC containers.
</p>
</div>
</div>
<div className="flex gap-3 pt-6">
<button
type="button"
onClick={() => {
setNewToken(null);
setShowSecret(false);
}}
className="flex-1 btn-primary py-2 px-4 rounded-md"
>
I've Saved the Credentials
</button>
</div>
</div>
</div>
</div>
)}
</SettingsLayout>
);
};

View File

@@ -1,7 +1,8 @@
import { Code, Server } from "lucide-react";
import { Code, Image, Server } from "lucide-react";
import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import SettingsLayout from "../../components/SettingsLayout";
import BrandingTab from "../../components/settings/BrandingTab";
import ProtocolUrlTab from "../../components/settings/ProtocolUrlTab";
import VersionUpdateTab from "../../components/settings/VersionUpdateTab";
@@ -12,6 +13,7 @@ const SettingsServerConfig = () => {
// Set initial tab based on current route
if (location.pathname === "/settings/server-version") return "version";
if (location.pathname === "/settings/server-url") return "protocol";
if (location.pathname === "/settings/branding") return "branding";
if (location.pathname === "/settings/server-config/version")
return "version";
return "protocol";
@@ -23,6 +25,8 @@ const SettingsServerConfig = () => {
setActiveTab("version");
} else if (location.pathname === "/settings/server-url") {
setActiveTab("protocol");
} else if (location.pathname === "/settings/branding") {
setActiveTab("branding");
} else if (location.pathname === "/settings/server-config/version") {
setActiveTab("version");
} else if (location.pathname === "/settings/server-config") {
@@ -37,6 +41,12 @@ const SettingsServerConfig = () => {
icon: Server,
href: "/settings/server-url",
},
{
id: "branding",
name: "Branding",
icon: Image,
href: "/settings/branding",
},
{
id: "version",
name: "Server Version",
@@ -49,6 +59,8 @@ const SettingsServerConfig = () => {
switch (activeTab) {
case "protocol":
return <ProtocolUrlTab />;
case "branding":
return <BrandingTab />;
case "version":
return <VersionUpdateTab />;
default:

View File

@@ -132,6 +132,7 @@ export const repositoryAPI = {
getByHost: (hostId) => api.get(`/repositories/host/${hostId}`),
update: (repositoryId, data) =>
api.put(`/repositories/${repositoryId}`, data),
delete: (repositoryId) => api.delete(`/repositories/${repositoryId}`),
toggleHostRepository: (hostId, repositoryId, isEnabled) =>
api.patch(`/repositories/host/${hostId}/repository/${repositoryId}`, {
isEnabled,
@@ -259,4 +260,9 @@ export const formatRelativeTime = (date) => {
return `${seconds} second${seconds > 1 ? "s" : ""} ago`;
};
// Search API
export const searchAPI = {
global: (query) => api.get("/search", { params: { q: query } }),
};
export default api;

View File

@@ -35,7 +35,7 @@ NC='\033[0m' # No Color
# Global variables
SCRIPT_VERSION="self-hosting-install.sh v1.2.7-selfhost-2025-01-20-1"
DEFAULT_GITHUB_REPO="https://github.com/9technologygroup/patchmon.net.git"
DEFAULT_GITHUB_REPO="https://github.com/PatchMon/PatchMon.git"
FQDN=""
CUSTOM_FQDN=""
EMAIL=""