1.2.7 Release

Please see the release notes.
This commit is contained in:
9 Technology Group LTD
2025-10-02 18:12:09 +01:00
committed by GitHub
48 changed files with 6618 additions and 704 deletions

17
.github/workflows/app_build.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: Build on Merge
on:
push:
branches:
- main
- dev
jobs:
deploy:
runs-on: self-hosted
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Run rebuild script
run: /root/patchmon/platform/scripts/app_build.sh ${{ github.ref_name }}

View File

@@ -33,24 +33,24 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'workflow_dispatch' || github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true'
- name: Log in to container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
# Using PAT as a hack due to issues with GITHUB_TOKEN and package permissions
# This should be reverted to use GITHUB_TOKEN once a solution is discovered.
password: ${{ secrets.GHCR_PAT }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/patchmon-${{ matrix.image }}
images: ${{ env.REGISTRY }}/${{ github.repository }}-${{ matrix.image }}
tags: |
type=ref,event=pr
type=semver,pattern={{version}}
@@ -59,13 +59,12 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push ${{ matrix.image }} image
if: github.event_name != 'workflow_dispatch' || github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true'
uses: docker/build-push-action@v6
with:
context: .
file: docker/${{ matrix.image }}.Dockerfile
platforms: linux/amd64,linux/arm64
push: true
push: ${{ github.event_name != 'workflow_dispatch' || inputs.push == 'true' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.image }}

2
.gitignore vendored
View File

@@ -130,6 +130,8 @@ agents/*.log
test-results/
playwright-report/
test-results.xml
test_*.sh
test-*.sh
# Package manager lock files (uncomment if you want to ignore them)
# package-lock.json

View File

@@ -11,6 +11,11 @@ CONFIG_FILE="/etc/patchmon/agent.conf"
CREDENTIALS_FILE="/etc/patchmon/credentials"
LOG_FILE="/var/log/patchmon-agent.log"
# This placeholder will be dynamically replaced by the server when serving this
# script based on the "ignore SSL self-signed" setting. If set to -k, curl will
# ignore certificate validation. Otherwise, it will be empty for secure default.
CURL_FLAGS=""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -20,7 +25,10 @@ NC='\033[0m' # No Color
# Logging function
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
# Try to write to log file, but don't fail if we can't
if [[ -w "$(dirname "$LOG_FILE")" ]] 2>/dev/null; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE" 2>/dev/null
fi
}
# Error handling
@@ -30,21 +38,21 @@ error() {
exit 1
}
# Info logging
# Info logging (cleaner output - only stdout, no duplicate logging)
info() {
echo -e "${BLUE}INFO: $1${NC}"
echo -e "${BLUE} $1${NC}"
log "INFO: $1"
}
# Success logging
# Success logging (cleaner output - only stdout, no duplicate logging)
success() {
echo -e "${GREEN}SUCCESS: $1${NC}"
echo -e "${GREEN} $1${NC}"
log "SUCCESS: $1"
}
# Warning logging
# Warning logging (cleaner output - only stdout, no duplicate logging)
warning() {
echo -e "${YELLOW}WARNING: $1${NC}"
echo -e "${YELLOW}⚠️ $1${NC}"
log "WARNING: $1"
}
@@ -55,6 +63,31 @@ check_root() {
fi
}
# Verify system datetime and timezone
verify_datetime() {
info "Verifying system datetime and timezone..."
# Get current system time
local system_time=$(date)
local timezone="Unknown"
# Try to get timezone with timeout protection
if command -v timedatectl >/dev/null 2>&1; then
timezone=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "Unknown")
fi
# Log datetime info (non-blocking)
log "System datetime check - time: $system_time, timezone: $timezone" 2>/dev/null || true
# Simple check - just log the info, don't block execution
if [[ "$timezone" == "Unknown" ]] || [[ -z "$timezone" ]]; then
warning "System timezone not configured: $timezone"
log "WARNING: System timezone not configured - timezone: $timezone" 2>/dev/null || true
fi
return 0
}
# Create necessary directories
setup_directories() {
mkdir -p /etc/patchmon
@@ -144,7 +177,7 @@ EOF
test_credentials() {
load_credentials
local response=$(curl -ks -X POST \
local response=$(curl $CURL_FLAGS -X POST \
-H "Content-Type: application/json" \
-H "X-API-ID: $API_ID" \
-H "X-API-KEY: $API_KEY" \
@@ -611,16 +644,31 @@ get_yum_packages() {
fi
# Get upgradable packages
local upgradable=$($package_manager check-update 2>/dev/null | grep -v "^$" | grep -v "^Loaded" | grep -v "^Last metadata" | tail -n +2)
local upgradable=$($package_manager check-update 2>/dev/null | grep -v "^$" | grep -v "^Loaded" | grep -v "^Last metadata" | grep -v "^Security" | tail -n +2)
while IFS= read -r line; do
# Skip empty lines and lines with special characters
[[ -z "$line" ]] && continue
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
if [[ "$line" =~ ^([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+) ]]; then
local package_name="${BASH_REMATCH[1]}"
local available_version="${BASH_REMATCH[2]}"
local repo="${BASH_REMATCH[3]}"
# Sanitize package name and versions (remove any control characters)
package_name=$(echo "$package_name" | tr -d '[:cntrl:]' | sed 's/[^a-zA-Z0-9._+-]//g')
available_version=$(echo "$available_version" | tr -d '[:cntrl:]' | sed 's/[^a-zA-Z0-9._+-]//g')
repo=$(echo "$repo" | tr -d '[:cntrl:]')
# Skip if package name is empty after sanitization
[[ -z "$package_name" ]] && continue
# Get current version
local current_version=$($package_manager list installed "$package_name" 2>/dev/null | grep "^$package_name" | awk '{print $2}')
local current_version=$($package_manager list installed "$package_name" 2>/dev/null | grep "^$package_name" | awk '{print $2}' | tr -d '[:cntrl:]' | sed 's/[^a-zA-Z0-9._+-]//g')
# Skip if we couldn't get current version
[[ -z "$current_version" ]] && current_version="unknown"
local is_security_update=false
if echo "$repo" | grep -q "security"; then
@@ -641,10 +689,22 @@ get_yum_packages() {
local installed=$($package_manager list installed 2>/dev/null | grep -v "^Loaded" | grep -v "^Installed" | head -100)
while IFS= read -r line; do
# Skip empty lines
[[ -z "$line" ]] && continue
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
if [[ "$line" =~ ^([^[:space:]]+)[[:space:]]+([^[:space:]]+) ]]; then
local package_name="${BASH_REMATCH[1]}"
local version="${BASH_REMATCH[2]}"
# Sanitize package name and version
package_name=$(echo "$package_name" | tr -d '[:cntrl:]' | sed 's/[^a-zA-Z0-9._+-]//g')
version=$(echo "$version" | tr -d '[:cntrl:]' | sed 's/[^a-zA-Z0-9._+-]//g')
# Skip if package name is empty after sanitization
[[ -z "$package_name" ]] && continue
[[ -z "$version" ]] && version="unknown"
# Check if this package is not in the upgrade list
if ! echo "$upgradable" | grep -q "^$package_name "; then
if [[ "$first_ref" == true ]]; then
@@ -678,11 +738,21 @@ get_hardware_info() {
# Memory Information
if command -v free >/dev/null 2>&1; then
ram_installed=$(free -g | grep "^Mem:" | awk '{print $2}')
swap_size=$(free -g | grep "^Swap:" | awk '{print $2}')
# Use free -m to get MB, then convert to GB with decimal precision
ram_installed=$(free -m | grep "^Mem:" | awk '{printf "%.2f", $2/1024}')
swap_size=$(free -m | grep "^Swap:" | awk '{printf "%.2f", $2/1024}')
elif [[ -f /proc/meminfo ]]; then
ram_installed=$(grep "MemTotal" /proc/meminfo | awk '{print int($2/1024/1024)}')
swap_size=$(grep "SwapTotal" /proc/meminfo | awk '{print int($2/1024/1024)}')
# Convert KB to GB with decimal precision
ram_installed=$(grep "MemTotal" /proc/meminfo | awk '{printf "%.2f", $2/1048576}')
swap_size=$(grep "SwapTotal" /proc/meminfo | awk '{printf "%.2f", $2/1048576}')
fi
# Ensure minimum value of 0.01GB to prevent 0 values
if (( $(echo "$ram_installed < 0.01" | bc -l) )); then
ram_installed="0.01"
fi
if (( $(echo "$swap_size < 0" | bc -l) )); then
swap_size="0"
fi
# Disk Information
@@ -740,8 +810,16 @@ get_system_info() {
# SELinux Status
if command -v getenforce >/dev/null 2>&1; then
selinux_status=$(getenforce 2>/dev/null | tr '[:upper:]' '[:lower:]')
# Map "enforcing" to "enabled" for server validation
if [[ "$selinux_status" == "enforcing" ]]; then
selinux_status="enabled"
fi
elif [[ -f /etc/selinux/config ]]; then
selinux_status=$(grep "^SELINUX=" /etc/selinux/config | cut -d'=' -f2 | tr '[:upper:]' '[:lower:]')
# Map "enforcing" to "enabled" for server validation
if [[ "$selinux_status" == "enforcing" ]]; then
selinux_status="enabled"
fi
else
selinux_status="disabled"
fi
@@ -771,19 +849,16 @@ get_system_info() {
send_update() {
load_credentials
info "Collecting package information..."
local packages_json=$(get_package_info)
info "Collecting repository information..."
local repositories_json=$(get_repository_info)
info "Collecting hardware information..."
local hardware_json=$(get_hardware_info)
info "Collecting network information..."
local network_json=$(get_network_info)
# Verify datetime before proceeding
if ! verify_datetime; then
warning "Datetime verification failed, but continuing with update..."
fi
info "Collecting system information..."
local packages_json=$(get_package_info)
local repositories_json=$(get_repository_info)
local hardware_json=$(get_hardware_info)
local network_json=$(get_network_info)
local system_json=$(get_system_info)
info "Sending update to PatchMon server..."
@@ -809,7 +884,7 @@ EOF
local payload=$(echo "$base_payload $merged_json" | jq -s '.[0] * .[1]')
local response=$(curl -ks -X POST \
local response=$(curl $CURL_FLAGS -X POST \
-H "Content-Type: application/json" \
-H "X-API-ID: $API_ID" \
-H "X-API-KEY: $API_KEY" \
@@ -818,45 +893,35 @@ EOF
if [[ $? -eq 0 ]]; then
if echo "$response" | grep -q "success"; then
success "Update sent successfully"
echo "$response" | grep -o '"packagesProcessed":[0-9]*' | cut -d':' -f2 | xargs -I {} info "Processed {} packages"
local packages_count=$(echo "$response" | grep -o '"packagesProcessed":[0-9]*' | cut -d':' -f2)
success "Update sent successfully (${packages_count} packages processed)"
# Check if auto-update is enabled and check for agent updates locally
if check_auto_update_enabled; then
info "Auto-update is enabled, checking for agent updates..."
info "Checking for agent updates..."
if check_agent_update_needed; then
info "Agent update available, automatically updating..."
info "Agent update available, updating..."
if "$0" update-agent; then
success "PatchMon agent update completed successfully"
success "Agent updated successfully"
else
warning "PatchMon agent update failed, but data was sent successfully"
warning "Agent update failed, but data was sent successfully"
fi
else
info "Agent is up to date"
fi
fi
# Check for crontab update instructions (look specifically in crontabUpdate section)
if echo "$response" | grep -q '"crontabUpdate":{'; then
local crontab_update_section=$(echo "$response" | grep -o '"crontabUpdate":{[^}]*}')
local should_update_crontab=$(echo "$crontab_update_section" | grep -o '"shouldUpdate":true' | cut -d':' -f2)
if [[ "$should_update_crontab" == "true" ]]; then
local crontab_message=$(echo "$crontab_update_section" | grep -o '"message":"[^"]*' | cut -d'"' -f4)
local crontab_command=$(echo "$crontab_update_section" | grep -o '"command":"[^"]*' | cut -d'"' -f4)
if [[ -n "$crontab_message" ]]; then
info "Crontab update detected: $crontab_message"
fi
if [[ "$crontab_command" == "update-crontab" ]]; then
info "Automatically updating crontab with new interval..."
if "$0" update-crontab; then
success "Crontab updated successfully"
else
warning "Crontab update failed, but data was sent successfully"
fi
fi
fi
# Automatically check if crontab needs updating based on server settings
info "Checking crontab configuration..."
"$0" update-crontab
local crontab_exit_code=$?
if [[ $crontab_exit_code -eq 0 ]]; then
success "Crontab updated successfully"
elif [[ $crontab_exit_code -eq 2 ]]; then
# Already up to date - no additional message needed
true
else
warning "Crontab update failed, but data was sent successfully"
fi
else
error "Update failed: $response"
@@ -870,7 +935,7 @@ EOF
ping_server() {
load_credentials
local response=$(curl -ks -X POST \
local response=$(curl $CURL_FLAGS -X POST \
-H "Content-Type: application/json" \
-H "X-API-ID: $API_ID" \
-H "X-API-KEY: $API_KEY" \
@@ -883,7 +948,7 @@ ping_server() {
info "Connected as host: $hostname"
fi
# Check for crontab update trigger
# Check for crontab update instructions
local should_update_crontab=$(echo "$response" | grep -o '"shouldUpdate":true' | cut -d':' -f2)
if [[ "$should_update_crontab" == "true" ]]; then
local message=$(echo "$response" | grep -o '"message":"[^"]*' | cut -d'"' -f4)
@@ -894,11 +959,16 @@ ping_server() {
fi
if [[ "$command" == "update-crontab" ]]; then
info "Automatically updating crontab with new interval..."
if "$0" update-crontab; then
info "Updating crontab with new interval..."
"$0" update-crontab
local crontab_exit_code=$?
if [[ $crontab_exit_code -eq 0 ]]; then
success "Crontab updated successfully"
elif [[ $crontab_exit_code -eq 2 ]]; then
# Already up to date - no additional message needed
true
else
warning "Crontab update failed, but ping was successful"
warning "Crontab update failed, but data was sent successfully"
fi
fi
fi
@@ -913,7 +983,7 @@ check_version() {
info "Checking for agent updates..."
local response=$(curl -ks -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/version")
local response=$(curl $CURL_FLAGS -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/version")
if [[ $? -eq 0 ]]; then
local current_version=$(echo "$response" | grep -o '"currentVersion":"[^"]*' | cut -d'"' -f4)
@@ -945,7 +1015,7 @@ check_version() {
# Check if auto-update is enabled (both globally and for this host)
check_auto_update_enabled() {
# Get settings from server using API credentials
local response=$(curl -ks -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/settings" 2>/dev/null)
local response=$(curl $CURL_FLAGS -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/settings" 2>/dev/null)
if [[ $? -ne 0 ]]; then
return 1
fi
@@ -970,7 +1040,7 @@ check_agent_update_needed() {
fi
# Get server agent info using API credentials
local response=$(curl -ks -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/timestamp" 2>/dev/null)
local response=$(curl $CURL_FLAGS -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/timestamp" 2>/dev/null)
if [[ $? -eq 0 ]]; then
local server_version=$(echo "$response" | grep -o '"version":"[^"]*' | cut -d'"' -f4)
@@ -1007,7 +1077,7 @@ check_agent_update() {
fi
# Get server agent info using API credentials
local response=$(curl -ks -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/timestamp")
local response=$(curl $CURL_FLAGS -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/timestamp")
if [[ $? -eq 0 ]]; then
local server_version=$(echo "$response" | grep -o '"version":"[^"]*' | cut -d'"' -f4)
@@ -1057,7 +1127,7 @@ update_agent() {
cp "$0" "$backup_file"
# Download new version using API credentials
if curl -ks -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -o "/tmp/patchmon-agent-new.sh" "$download_url"; then
if curl $CURL_FLAGS -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -o "/tmp/patchmon-agent-new.sh" "$download_url"; then
# Verify the downloaded script is valid
if bash -n "/tmp/patchmon-agent-new.sh" 2>/dev/null; then
# Replace current script
@@ -1090,7 +1160,7 @@ update_agent() {
update_crontab() {
load_credentials
info "Updating crontab with current policy..."
local response=$(curl -ks -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/settings/update-interval")
local response=$(curl $CURL_FLAGS -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/settings/update-interval")
if [[ $? -eq 0 ]]; then
local update_interval=$(echo "$response" | grep -o '"updateInterval":[0-9]*' | cut -d':' -f2)
# Fallback if not found
@@ -1129,7 +1199,7 @@ update_crontab() {
# Check if crontab needs updating
if [[ "$current_patchmon_entry" == "$expected_crontab" ]]; then
info "Crontab is already up to date (interval: $update_interval minutes)"
return 0
return 2 # Special return code for "already up to date"
fi
info "Setting update interval to $update_interval minutes"

View File

@@ -1,10 +1,15 @@
#!/bin/bash
# PatchMon Agent Installation Script
# Usage: curl -ks {PATCHMON_URL}/api/v1/hosts/install -H "X-API-ID: {API_ID}" -H "X-API-KEY: {API_KEY}" | bash
# Usage: curl -s {PATCHMON_URL}/api/v1/hosts/install -H "X-API-ID: {API_ID}" -H "X-API-KEY: {API_KEY}" | bash
set -e
# This placeholder will be dynamically replaced by the server when serving this
# script based on the "ignore SSL self-signed" setting. If set to -k, curl will
# ignore certificate validation. Otherwise, it will be empty for secure default.
# CURL_FLAGS is now set via environment variables by the backend
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -35,6 +40,60 @@ if [[ $EUID -ne 0 ]]; then
error "This script must be run as root (use sudo)"
fi
# Verify system datetime and timezone
verify_datetime() {
info "🕐 Verifying system datetime and timezone..."
# Get current system time
local system_time=$(date)
local timezone=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "Unknown")
# Display current datetime info
echo ""
echo -e "${BLUE}📅 Current System Date/Time:${NC}"
echo " • Date/Time: $system_time"
echo " • Timezone: $timezone"
echo ""
# Check if we can read from stdin (interactive terminal)
if [[ -t 0 ]]; then
# Interactive terminal - ask user
read -p "Does this date/time look correct to you? (y/N): " -r response
if [[ "$response" =~ ^[Yy]$ ]]; then
success "✅ Date/time verification passed"
echo ""
return 0
else
echo ""
echo -e "${RED}❌ Date/time verification failed${NC}"
echo ""
echo -e "${YELLOW}💡 Please fix the date/time and re-run the installation script:${NC}"
echo " sudo timedatectl set-time 'YYYY-MM-DD HH:MM:SS'"
echo " sudo timedatectl set-timezone 'America/New_York' # or your timezone"
echo " sudo timedatectl list-timezones # to see available timezones"
echo ""
echo -e "${BLUE} After fixing the date/time, re-run this installation script.${NC}"
error "Installation cancelled - please fix date/time and re-run"
fi
else
# Non-interactive (piped from curl) - show warning and continue
echo -e "${YELLOW}⚠️ Non-interactive installation detected${NC}"
echo ""
echo "Please verify the date/time shown above is correct."
echo "If the date/time is incorrect, it may cause issues with:"
echo " • Logging timestamps"
echo " • Scheduled updates"
echo " • Data synchronization"
echo ""
echo -e "${GREEN}✅ Continuing with installation...${NC}"
success "✅ Date/time verification completed (assumed correct)"
echo ""
fi
}
# Run datetime verification
verify_datetime
# Clean up old files (keep only last 3 of each type)
cleanup_old_files() {
# Clean up old credential backups
@@ -59,33 +118,67 @@ info "🚀 Starting PatchMon Agent Installation..."
info "📋 Server: $PATCHMON_URL"
info "🔑 API ID: ${API_ID:0:16}..."
# Display diagnostic information
echo ""
echo -e "${BLUE}🔧 Installation Diagnostics:${NC}"
echo " • URL: $PATCHMON_URL"
echo " • CURL FLAGS: $CURL_FLAGS"
echo " • API ID: ${API_ID:0:16}..."
echo " • API Key: ${API_KEY:0:16}..."
echo ""
# Install required dependencies
info "📦 Installing required dependencies..."
echo ""
# Detect package manager and install jq and curl
if command -v apt-get >/dev/null 2>&1; then
# Debian/Ubuntu
apt-get update >/dev/null 2>&1
apt-get install -y jq curl >/dev/null 2>&1
info "Detected apt-get (Debian/Ubuntu)"
echo ""
info "Updating package lists..."
apt-get update
echo ""
info "Installing jq, curl, and bc..."
apt-get install jq curl bc -y
elif command -v yum >/dev/null 2>&1; then
# CentOS/RHEL 7
yum install -y jq curl >/dev/null 2>&1
info "Detected yum (CentOS/RHEL 7)"
echo ""
info "Installing jq, curl, and bc..."
yum install -y jq curl bc
elif command -v dnf >/dev/null 2>&1; then
# CentOS/RHEL 8+/Fedora
dnf install -y jq curl >/dev/null 2>&1
info "Detected dnf (CentOS/RHEL 8+/Fedora)"
echo ""
info "Installing jq, curl, and bc..."
dnf install -y jq curl bc
elif command -v zypper >/dev/null 2>&1; then
# openSUSE
zypper install -y jq curl >/dev/null 2>&1
info "Detected zypper (openSUSE)"
echo ""
info "Installing jq, curl, and bc..."
zypper install -y jq curl bc
elif command -v pacman >/dev/null 2>&1; then
# Arch Linux
pacman -S --noconfirm jq curl >/dev/null 2>&1
info "Detected pacman (Arch Linux)"
echo ""
info "Installing jq, curl, and bc..."
pacman -S --noconfirm jq curl bc
elif command -v apk >/dev/null 2>&1; then
# Alpine Linux
apk add --no-cache jq curl >/dev/null 2>&1
info "Detected apk (Alpine Linux)"
echo ""
info "Installing jq, curl, and bc..."
apk add --no-cache jq curl bc
else
warning "Could not detect package manager. Please ensure 'jq' and 'curl' are installed manually."
warning "Could not detect package manager. Please ensure 'jq', 'curl', and 'bc' are installed manually."
fi
echo ""
success "Dependencies installation completed"
echo ""
# Step 1: Handle existing configuration directory
info "📁 Setting up configuration directory..."
@@ -145,7 +238,7 @@ if [[ -f "/usr/local/bin/patchmon-agent.sh" ]]; then
info "📋 Moved existing agent to: /usr/local/bin/patchmon-agent.sh.backup.$(date +%Y%m%d_%H%M%S)"
fi
curl -ks \
curl $CURL_FLAGS \
-H "X-API-ID: $API_ID" \
-H "X-API-KEY: $API_KEY" \
"$PATCHMON_URL/api/v1/hosts/agent/download" \
@@ -170,93 +263,30 @@ fi
# Step 4: Test the configuration
info "🧪 Testing API credentials and connectivity..."
if /usr/local/bin/patchmon-agent.sh test; then
success "✅ API credentials are valid and server is reachable"
success "✅ TEST: API credentials are valid and server is reachable"
else
error "❌ Failed to validate API credentials or reach server"
fi
# Step 5: Send initial data
# Step 5: Send initial data and setup automated updates
info "📊 Sending initial package data to server..."
if /usr/local/bin/patchmon-agent.sh update; then
success "✅ Initial package data sent successfully"
success "✅ UPDATE: Initial package data sent successfully"
info "✅ Automated updates configured by agent"
else
warning "⚠️ Failed to send initial data. You can retry later with: /usr/local/bin/patchmon-agent.sh update"
fi
# Step 6: Get update interval policy from server and setup crontab
info "⏰ Getting update interval policy from server..."
UPDATE_INTERVAL=$(curl -ks \
-H "X-API-ID: $API_ID" \
-H "X-API-KEY: $API_KEY" \
"$PATCHMON_URL/api/v1/settings/update-interval" | \
grep -o '"updateInterval":[0-9]*' | cut -d':' -f2 2>/dev/null || echo "60")
info "📋 Update interval: $UPDATE_INTERVAL minutes"
# Setup crontab (smart duplicate detection)
info "📅 Setting up automated updates..."
# Check if PatchMon cron entries already exist
if crontab -l 2>/dev/null | grep -q "/usr/local/bin/patchmon-agent.sh update"; then
warning "⚠️ Existing PatchMon cron entries found"
warning "⚠️ These will be replaced with new schedule"
fi
# Function to setup crontab without duplicates
setup_crontab() {
local update_interval="$1"
local patchmon_pattern="/usr/local/bin/patchmon-agent.sh update"
# Normalize interval: min 5, max 1440
if [[ -z "$update_interval" ]]; then update_interval=60; fi
if [[ "$update_interval" -lt 5 ]]; then update_interval=5; fi
if [[ "$update_interval" -gt 1440 ]]; then update_interval=1440; fi
# Get current crontab, remove any existing patchmon entries
local current_cron=$(crontab -l 2>/dev/null | grep -v "$patchmon_pattern" || true)
# Determine new cron entry
local new_entry
if [[ "$update_interval" -lt 60 ]]; then
# Every N minutes (5-59)
new_entry="*/$update_interval * * * * $patchmon_pattern >/dev/null 2>&1"
info "📋 Configuring updates every $update_interval minutes"
else
if [[ "$update_interval" -eq 60 ]]; then
# Hourly updates - use current minute to spread load
local current_minute=$(date +%M)
new_entry="$current_minute * * * * $patchmon_pattern >/dev/null 2>&1"
info "📋 Configuring hourly updates at minute $current_minute"
else
# For 120, 180, 360, 720, 1440 -> every H hours at minute 0
local hours=$((update_interval / 60))
new_entry="0 */$hours * * * $patchmon_pattern >/dev/null 2>&1"
info "📋 Configuring updates every $hours hour(s)"
fi
fi
# Combine existing cron (without patchmon entries) + new entry
{
if [[ -n "$current_cron" ]]; then
echo "$current_cron"
fi
echo "$new_entry"
} | crontab -
success "✅ Crontab configured successfully (duplicates removed)"
}
setup_crontab "$UPDATE_INTERVAL"
# Installation complete
success "🎉 PatchMon Agent installation completed successfully!"
echo ""
echo -e "${GREEN}📋 Installation Summary:${NC}"
echo " • Configuration directory: /etc/patchmon"
echo " • Agent installed: /usr/local/bin/patchmon-agent.sh"
echo " • Dependencies installed: jq, curl"
echo " • Crontab configured for automatic updates"
echo " • Dependencies installed: jq, curl, bc"
echo " • Automated updates configured via crontab"
echo " • API credentials configured and tested"
echo " • Update schedule managed by agent"
# Check for moved files and show them
MOVED_FILES=$(ls /etc/patchmon/credentials.backup.* /usr/local/bin/patchmon-agent.sh.backup.* /var/log/patchmon-agent.log.old.* 2>/dev/null || true)

View File

@@ -1,11 +1,16 @@
#!/bin/bash
# PatchMon Agent Removal Script
# Usage: curl -ks {PATCHMON_URL}/api/v1/hosts/remove | bash
# Usage: curl -s {PATCHMON_URL}/api/v1/hosts/remove | bash
# This script completely removes PatchMon from the system
set -e
# This placeholder will be dynamically replaced by the server when serving this
# script based on the "ignore SSL self-signed" setting for any curl calls in
# future (left for consistency with install script).
CURL_FLAGS=""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'

View File

@@ -23,3 +23,9 @@ ENABLE_LOGGING=true
# User Registration
DEFAULT_USER_ROLE=user
# JWT Configuration
JWT_SECRET=your-secure-random-secret-key-change-this-in-production
JWT_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=7d
SESSION_INACTIVITY_TIMEOUT_MINUTES=30

View File

@@ -0,0 +1,10 @@
-- Fix dashboard preferences unique constraint
-- This migration fixes the unique constraint on dashboard_preferences table
-- Drop existing indexes if they exist
DROP INDEX IF EXISTS "dashboard_preferences_card_id_key";
DROP INDEX IF EXISTS "dashboard_preferences_user_id_card_id_key";
DROP INDEX IF EXISTS "dashboard_preferences_user_id_key";
-- Add the correct unique constraint
ALTER TABLE "dashboard_preferences" ADD CONSTRAINT "dashboard_preferences_user_id_card_id_key" UNIQUE ("user_id", "card_id");

View File

@@ -0,0 +1,4 @@
-- Add ignore_ssl_self_signed column to settings table
-- This allows users to configure whether curl commands should ignore SSL certificate validation
ALTER TABLE "settings" ADD COLUMN "ignore_ssl_self_signed" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "hosts" ADD COLUMN "notes" TEXT;

View File

@@ -0,0 +1,31 @@
-- CreateTable
CREATE TABLE "user_sessions" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"refresh_token" TEXT NOT NULL,
"access_token_hash" TEXT,
"ip_address" TEXT,
"user_agent" TEXT,
"last_activity" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"is_revoked" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "user_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_sessions_refresh_token_key" ON "user_sessions"("refresh_token");
-- CreateIndex
CREATE INDEX "user_sessions_user_id_idx" ON "user_sessions"("user_id");
-- CreateIndex
CREATE INDEX "user_sessions_refresh_token_idx" ON "user_sessions"("refresh_token");
-- CreateIndex
CREATE INDEX "user_sessions_expires_at_idx" ON "user_sessions"("expires_at");
-- AddForeignKey
ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -7,7 +7,6 @@ datasource db {
url = env("DATABASE_URL")
}
model dashboard_preferences {
id String @id
user_id String
@@ -87,6 +86,7 @@ model hosts {
selinux_status String?
swap_size Int?
system_uptime String?
notes String?
host_packages host_packages[]
host_repositories host_repositories[]
host_groups host_groups? @relation(fields: [host_group_id], references: [id])
@@ -157,6 +157,7 @@ model settings {
update_available Boolean @default(false)
signup_enabled Boolean @default(false)
default_user_role String @default("user")
ignore_ssl_self_signed Boolean @default(false)
}
model update_history {
@@ -175,8 +176,6 @@ model users {
username String @unique
email String @unique
password_hash String
first_name String?
last_name String?
role String @default("admin")
is_active Boolean @default(true)
last_login DateTime?
@@ -185,5 +184,26 @@ model users {
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[]
}
model user_sessions {
id String @id
user_id String
refresh_token String @unique
access_token_hash String?
ip_address String?
user_agent String?
last_activity DateTime @default(now())
expires_at DateTime
created_at DateTime @default(now())
is_revoked Boolean @default(false)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@index([user_id])
@@index([refresh_token])
@@index([expires_at])
}

View File

@@ -1,9 +1,13 @@
const jwt = require("jsonwebtoken");
const { PrismaClient } = require("@prisma/client");
const {
validate_session,
update_session_activity,
} = require("../utils/session_manager");
const prisma = new PrismaClient();
// Middleware to verify JWT token
// Middleware to verify JWT token with session validation
const authenticateToken = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
@@ -19,35 +23,40 @@ const authenticateToken = async (req, res, next) => {
process.env.JWT_SECRET || "your-secret-key",
);
// Get user from database
const user = await prisma.users.findUnique({
where: { id: decoded.userId },
select: {
id: true,
username: true,
email: true,
role: true,
is_active: true,
last_login: true,
created_at: true,
updated_at: true,
},
});
// Validate session and check inactivity timeout
const validation = await validate_session(decoded.sessionId, token);
if (!user || !user.is_active) {
return res.status(401).json({ error: "Invalid or inactive user" });
if (!validation.valid) {
const error_messages = {
"Session not found": "Session not found",
"Session revoked": "Session has been revoked",
"Session expired": "Session has expired",
"Session inactive":
validation.message || "Session timed out due to inactivity",
"Token mismatch": "Invalid token",
"User inactive": "User account is inactive",
};
return res.status(401).json({
error: error_messages[validation.reason] || "Authentication failed",
reason: validation.reason,
});
}
// Update last login
// Update session activity timestamp
await update_session_activity(decoded.sessionId);
// Update last login (only on successful authentication)
await prisma.users.update({
where: { id: user.id },
where: { id: validation.user.id },
data: {
last_login: new Date(),
updated_at: new Date(),
},
});
req.user = user;
req.user = validation.user;
req.session_id = decoded.sessionId;
next();
} catch (error) {
if (error.name === "JsonWebTokenError") {

View File

@@ -12,6 +12,13 @@ const { v4: uuidv4 } = require("uuid");
const {
createDefaultDashboardPreferences,
} = require("./dashboardPreferencesRoutes");
const {
create_session,
refresh_access_token,
revoke_session,
revoke_all_user_sessions,
get_user_sessions,
} = require("../utils/session_manager");
const router = express.Router();
const prisma = new PrismaClient();
@@ -118,12 +125,16 @@ router.post(
// Create default dashboard preferences for the new admin user
await createDefaultDashboardPreferences(user.id, "admin");
// Generate token for immediate login
const token = generateToken(user.id);
// Create session for immediate login
const ip_address = req.ip || req.connection.remoteAddress;
const user_agent = req.get("user-agent");
const session = await create_session(user.id, ip_address, user_agent);
res.status(201).json({
message: "Admin user created successfully",
token,
token: session.access_token,
refresh_token: session.refresh_token,
expires_at: session.expires_at,
user: {
id: user.id,
username: user.username,
@@ -722,12 +733,16 @@ router.post(
},
});
// Generate token
const token = generateToken(user.id);
// Create session with access and refresh tokens
const ip_address = req.ip || req.connection.remoteAddress;
const user_agent = req.get("user-agent");
const session = await create_session(user.id, ip_address, user_agent);
res.json({
message: "Login successful",
token,
token: session.access_token,
refresh_token: session.refresh_token,
expires_at: session.expires_at,
user: {
id: user.id,
username: user.username,
@@ -829,12 +844,16 @@ router.post(
data: { last_login: new Date() },
});
// Generate token
const jwtToken = generateToken(user.id);
// Create session with access and refresh tokens
const ip_address = req.ip || req.connection.remoteAddress;
const user_agent = req.get("user-agent");
const session = await create_session(user.id, ip_address, user_agent);
res.json({
message: "Login successful",
token: jwtToken,
token: session.access_token,
refresh_token: session.refresh_token,
expires_at: session.expires_at,
user: {
id: user.id,
username: user.username,
@@ -1001,9 +1020,14 @@ router.put(
},
);
// Logout (client-side token removal)
router.post("/logout", authenticateToken, async (_req, res) => {
// Logout (revoke current session)
router.post("/logout", authenticateToken, async (req, res) => {
try {
// Revoke the current session
if (req.session_id) {
await revoke_session(req.session_id);
}
res.json({
message: "Logout successful",
});
@@ -1013,4 +1037,94 @@ router.post("/logout", authenticateToken, async (_req, res) => {
}
});
// Logout all sessions (revoke all user sessions)
router.post("/logout-all", authenticateToken, async (req, res) => {
try {
await revoke_all_user_sessions(req.user.id);
res.json({
message: "All sessions logged out successfully",
});
} catch (error) {
console.error("Logout all error:", error);
res.status(500).json({ error: "Logout all failed" });
}
});
// Refresh access token using refresh token
router.post(
"/refresh-token",
[body("refresh_token").notEmpty().withMessage("Refresh token is required")],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { refresh_token } = req.body;
const result = await refresh_access_token(refresh_token);
if (!result.success) {
return res.status(401).json({ error: result.error });
}
res.json({
message: "Token refreshed successfully",
token: result.access_token,
user: {
id: result.user.id,
username: result.user.username,
email: result.user.email,
role: result.user.role,
is_active: result.user.is_active,
},
});
} catch (error) {
console.error("Refresh token error:", error);
res.status(500).json({ error: "Token refresh failed" });
}
},
);
// Get user's active sessions
router.get("/sessions", authenticateToken, async (req, res) => {
try {
const sessions = await get_user_sessions(req.user.id);
res.json({
sessions: sessions,
});
} catch (error) {
console.error("Get sessions error:", error);
res.status(500).json({ error: "Failed to fetch sessions" });
}
});
// Revoke a specific session
router.delete("/sessions/:session_id", authenticateToken, async (req, res) => {
try {
const { session_id } = req.params;
// Verify the session belongs to the user
const session = await prisma.user_sessions.findUnique({
where: { id: session_id },
});
if (!session || session.user_id !== req.user.id) {
return res.status(404).json({ error: "Session not found" });
}
await revoke_session(session_id);
res.json({
message: "Session revoked successfully",
});
} catch (error) {
console.error("Revoke session error:", error);
res.status(500).json({ error: "Failed to revoke session" });
}
});
module.exports = router;

View File

@@ -194,6 +194,7 @@ router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => {
status: true,
agent_version: true,
auto_update: true,
notes: true,
host_groups: {
select: {
id: true,

View File

@@ -205,11 +205,11 @@ router.delete(
return res.status(404).json({ error: "Host group not found" });
}
// Check if host group has hosts
// If host group has hosts, ungroup them first
if (existingGroup._count.hosts > 0) {
return res.status(400).json({
error:
"Cannot delete host group that contains hosts. Please move or remove hosts first.",
await prisma.hosts.updateMany({
where: { host_group_id: id },
data: { host_group_id: null },
});
}

View File

@@ -45,11 +45,26 @@ router.get("/agent/download", async (req, res) => {
}
// Read file and convert line endings
const scriptContent = fs
let scriptContent = fs
.readFileSync(agentPath, "utf8")
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
// Determine curl flags dynamically from settings for consistency
let curlFlags = "-s";
try {
const settings = await prisma.settings.findFirst();
if (settings && settings.ignore_ssl_self_signed === true) {
curlFlags = "-sk";
}
} catch (_) {}
// Inject the curl flags into the script
scriptContent = scriptContent.replace(
'CURL_FLAGS=""',
`CURL_FLAGS="${curlFlags}"`,
);
res.setHeader("Content-Type", "application/x-shellscript");
res.setHeader(
"Content-Disposition",
@@ -266,12 +281,12 @@ router.post(
.withMessage("CPU cores must be a positive integer"),
body("ramInstalled")
.optional()
.isInt({ min: 1 })
.withMessage("RAM installed must be a positive integer"),
.isFloat({ min: 0.01 })
.withMessage("RAM installed must be a positive number"),
body("swapSize")
.optional()
.isInt({ min: 0 })
.withMessage("Swap size must be a non-negative integer"),
.isFloat({ min: 0 })
.withMessage("Swap size must be a non-negative number"),
body("diskDetails")
.optional()
.isArray()
@@ -843,6 +858,7 @@ router.get(
auto_update: true,
created_at: true,
host_group_id: true,
notes: true,
host_groups: {
select: {
id: true,
@@ -1101,11 +1117,21 @@ router.get("/install", async (req, res) => {
);
}
// Inject the API credentials and server URL into the script as environment variables
// Determine curl flags dynamically from settings (ignore self-signed)
let curlFlags = "-s";
try {
const settings = await prisma.settings.findFirst();
if (settings && settings.ignore_ssl_self_signed === true) {
curlFlags = "-sk";
}
} catch (_) {}
// Inject the API credentials, server URL, and curl flags 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}"
`;
@@ -1141,7 +1167,24 @@ router.get("/remove", async (_req, res) => {
}
// Read the script content
const script = fs.readFileSync(scriptPath, "utf8");
let script = fs.readFileSync(scriptPath, "utf8");
// Convert line endings
script = script.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// Determine curl flags dynamically from settings for consistency
let curlFlags = "-s";
try {
const settings = await prisma.settings.findFirst();
if (settings && settings.ignore_ssl_self_signed === true) {
curlFlags = "-sk";
}
} catch (_) {}
// Prepend environment for CURL_FLAGS so script can use it if needed
const envPrefix = `#!/bin/bash\nexport CURL_FLAGS="${curlFlags}"\n\n`;
script = script.replace(/^#!/, "#");
script = envPrefix + script;
// Set appropriate headers for script download
res.setHeader("Content-Type", "text/plain");
@@ -1449,4 +1492,78 @@ router.patch(
},
);
// Update host notes (admin only)
router.patch(
"/:hostId/notes",
authenticateToken,
requireManageHosts,
[
body("notes")
.optional()
.isLength({ max: 1000 })
.withMessage("Notes must be less than 1000 characters"),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { hostId } = req.params;
const { notes } = req.body;
// Check if host exists
const existingHost = await prisma.hosts.findUnique({
where: { id: hostId },
});
if (!existingHost) {
return res.status(404).json({ error: "Host not found" });
}
// Update the notes
const updatedHost = await prisma.hosts.update({
where: { id: hostId },
data: {
notes: notes || null,
updated_at: new Date(),
},
select: {
id: true,
friendly_name: true,
hostname: true,
ip: true,
os_type: true,
os_version: true,
architecture: true,
last_update: true,
status: true,
host_group_id: true,
agent_version: true,
auto_update: true,
created_at: true,
updated_at: true,
notes: true,
host_groups: {
select: {
id: true,
name: true,
color: true,
},
},
},
});
res.json({
message: "Notes updated successfully",
host: updatedHost,
});
} catch (error) {
console.error("Update notes error:", error);
res.status(500).json({ error: "Failed to update notes" });
}
},
);
module.exports = router;

View File

@@ -165,19 +165,31 @@ router.put(
requireManageSettings,
[
body("serverProtocol")
.optional()
.isIn(["http", "https"])
.withMessage("Protocol must be http or https"),
body("serverHost")
.optional()
.isLength({ min: 1 })
.withMessage("Server host is required"),
body("serverPort")
.optional()
.isInt({ min: 1, max: 65535 })
.withMessage("Port must be between 1 and 65535"),
body("updateInterval")
.optional()
.isInt({ min: 5, max: 1440 })
.withMessage("Update interval must be between 5 and 1440 minutes"),
body("autoUpdate").isBoolean().withMessage("Auto update must be a boolean"),
body("autoUpdate")
.optional()
.isBoolean()
.withMessage("Auto update must be a boolean"),
body("ignoreSslSelfSigned")
.optional()
.isBoolean()
.withMessage("Ignore SSL self-signed must be a boolean"),
body("signupEnabled")
.optional()
.isBoolean()
.withMessage("Signup enabled must be a boolean"),
body("defaultUserRole")
@@ -218,6 +230,7 @@ router.put(
serverPort,
updateInterval,
autoUpdate,
ignoreSslSelfSigned,
signupEnabled,
defaultUserRole,
githubRepoUrl,
@@ -229,32 +242,43 @@ router.put(
const currentSettings = await getSettings();
const oldUpdateInterval = currentSettings.update_interval;
// Update settings using the service
const normalizedInterval = normalizeUpdateInterval(updateInterval || 60);
// Build update object with only provided fields
const updateData = {};
const updatedSettings = await updateSettings(currentSettings.id, {
server_protocol: serverProtocol,
server_host: serverHost,
server_port: serverPort,
update_interval: normalizedInterval,
auto_update: autoUpdate || false,
signup_enabled: signupEnabled || false,
default_user_role:
defaultUserRole || process.env.DEFAULT_USER_ROLE || "user",
github_repo_url:
githubRepoUrl !== undefined
? githubRepoUrl
: "git@github.com:9technologygroup/patchmon.net.git",
repository_type: repositoryType || "public",
ssh_key_path: sshKeyPath || null,
});
if (serverProtocol !== undefined)
updateData.server_protocol = serverProtocol;
if (serverHost !== undefined) updateData.server_host = serverHost;
if (serverPort !== undefined) updateData.server_port = serverPort;
if (updateInterval !== undefined) {
updateData.update_interval = normalizeUpdateInterval(updateInterval);
}
if (autoUpdate !== undefined) updateData.auto_update = autoUpdate;
if (ignoreSslSelfSigned !== undefined)
updateData.ignore_ssl_self_signed = ignoreSslSelfSigned;
if (signupEnabled !== undefined)
updateData.signup_enabled = signupEnabled;
if (defaultUserRole !== undefined)
updateData.default_user_role = defaultUserRole;
if (githubRepoUrl !== undefined)
updateData.github_repo_url = githubRepoUrl;
if (repositoryType !== undefined)
updateData.repository_type = repositoryType;
if (sshKeyPath !== undefined) updateData.ssh_key_path = sshKeyPath;
const updatedSettings = await updateSettings(
currentSettings.id,
updateData,
);
console.log("Settings updated successfully:", updatedSettings);
// If update interval changed, trigger crontab updates on all hosts with auto-update enabled
if (oldUpdateInterval !== normalizedInterval) {
if (
updateInterval !== undefined &&
oldUpdateInterval !== updateData.update_interval
) {
console.log(
`Update interval changed from ${oldUpdateInterval} to ${normalizedInterval} minutes. Triggering crontab updates...`,
`Update interval changed from ${oldUpdateInterval} to ${updateData.update_interval} minutes. Triggering crontab updates...`,
);
await triggerCrontabUpdates();
}

View File

@@ -1,4 +1,40 @@
require("dotenv").config();
// Validate required environment variables on startup
function validateEnvironmentVariables() {
const requiredVars = {
JWT_SECRET: "Required for secure authentication token generation",
DATABASE_URL: "Required for database connection",
};
const missing = [];
// Check required variables
for (const [varName, description] of Object.entries(requiredVars)) {
if (!process.env[varName]) {
missing.push(`${varName}: ${description}`);
}
}
// Fail if required variables are missing
if (missing.length > 0) {
console.error("❌ Missing required environment variables:");
for (const error of missing) {
console.error(` - ${error}`);
}
console.error("");
console.error(
"Please set these environment variables and restart the application.",
);
process.exit(1);
}
console.log("✅ Environment variable validation passed");
}
// Validate environment variables before importing any modules that depend on them
validateEnvironmentVariables();
const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
@@ -26,6 +62,7 @@ const versionRoutes = require("./routes/versionRoutes");
const tfaRoutes = require("./routes/tfaRoutes");
const updateScheduler = require("./services/updateScheduler");
const { initSettings } = require("./services/settingsService");
const { cleanup_expired_sessions } = require("./utils/session_manager");
// Initialize Prisma client with optimized connection pooling for multiple instances
const prisma = createPrismaClient();
@@ -399,6 +436,9 @@ process.on("SIGINT", async () => {
if (process.env.ENABLE_LOGGING === "true") {
logger.info("SIGINT received, shutting down gracefully");
}
if (app.locals.session_cleanup_interval) {
clearInterval(app.locals.session_cleanup_interval);
}
updateScheduler.stop();
await disconnectPrisma(prisma);
process.exit(0);
@@ -408,6 +448,9 @@ process.on("SIGTERM", async () => {
if (process.env.ENABLE_LOGGING === "true") {
logger.info("SIGTERM received, shutting down gracefully");
}
if (app.locals.session_cleanup_interval) {
clearInterval(app.locals.session_cleanup_interval);
}
updateScheduler.stop();
await disconnectPrisma(prisma);
process.exit(0);
@@ -671,15 +714,35 @@ async function startServer() {
// Initialize dashboard preferences for all users
await initializeDashboardPreferences();
// Initial session cleanup
await cleanup_expired_sessions();
// Schedule session cleanup every hour
const session_cleanup_interval = setInterval(
async () => {
try {
await cleanup_expired_sessions();
} catch (error) {
console.error("Session cleanup error:", error);
}
},
60 * 60 * 1000,
); // Every hour
app.listen(PORT, () => {
if (process.env.ENABLE_LOGGING === "true") {
logger.info(`Server running on port ${PORT}`);
logger.info(`Environment: ${process.env.NODE_ENV}`);
logger.info("✅ Session cleanup scheduled (every hour)");
}
// Start update scheduler
updateScheduler.start();
});
// Store interval for cleanup on shutdown
app.locals.session_cleanup_interval = session_cleanup_interval;
} catch (error) {
console.error("❌ Failed to start server:", error.message);
process.exit(1);

View File

@@ -43,6 +43,7 @@ async function createSettingsFromEnvironment() {
update_interval: 60,
auto_update: false,
signup_enabled: false,
ignore_ssl_self_signed: false,
updated_at: new Date(),
},
});

View File

@@ -0,0 +1,319 @@
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
/**
* Session Manager - Handles secure session management with inactivity timeout
*/
// Configuration
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
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(
process.env.SESSION_INACTIVITY_TIMEOUT_MINUTES || "30",
10,
);
/**
* Generate access token (short-lived)
*/
function generate_access_token(user_id, session_id) {
return jwt.sign({ userId: user_id, sessionId: session_id }, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
});
}
/**
* Generate refresh token (long-lived)
*/
function generate_refresh_token() {
return crypto.randomBytes(64).toString("hex");
}
/**
* Hash token for storage
*/
function hash_token(token) {
return crypto.createHash("sha256").update(token).digest("hex");
}
/**
* Parse expiration string to Date
*/
function parse_expiration(expiration_string) {
const match = expiration_string.match(/^(\d+)([smhd])$/);
if (!match) {
throw new Error("Invalid expiration format");
}
const value = parseInt(match[1], 10);
const unit = match[2];
const now = new Date();
switch (unit) {
case "s":
return new Date(now.getTime() + value * 1000);
case "m":
return new Date(now.getTime() + value * 60 * 1000);
case "h":
return new Date(now.getTime() + value * 60 * 60 * 1000);
case "d":
return new Date(now.getTime() + value * 24 * 60 * 60 * 1000);
default:
throw new Error("Invalid time unit");
}
}
/**
* Create a new session for user
*/
async function create_session(user_id, ip_address, user_agent) {
try {
const session_id = crypto.randomUUID();
const refresh_token = generate_refresh_token();
const access_token = generate_access_token(user_id, session_id);
const expires_at = parse_expiration(JWT_REFRESH_EXPIRES_IN);
// Store session in database
await prisma.user_sessions.create({
data: {
id: session_id,
user_id: user_id,
refresh_token: hash_token(refresh_token),
access_token_hash: hash_token(access_token),
ip_address: ip_address || null,
user_agent: user_agent || null,
last_activity: new Date(),
expires_at: expires_at,
},
});
return {
session_id,
access_token,
refresh_token,
expires_at,
};
} catch (error) {
console.error("Error creating session:", error);
throw error;
}
}
/**
* Validate session and check for inactivity timeout
*/
async function validate_session(session_id, access_token) {
try {
const session = await prisma.user_sessions.findUnique({
where: { id: session_id },
include: { users: true },
});
if (!session) {
return { valid: false, reason: "Session not found" };
}
// Check if session is revoked
if (session.is_revoked) {
return { valid: false, reason: "Session revoked" };
}
// Check if session has expired
if (new Date() > session.expires_at) {
await revoke_session(session_id);
return { valid: false, reason: "Session expired" };
}
// Check for inactivity timeout
const inactivity_threshold = new Date(
Date.now() - INACTIVITY_TIMEOUT_MINUTES * 60 * 1000,
);
if (session.last_activity < inactivity_threshold) {
await revoke_session(session_id);
return {
valid: false,
reason: "Session inactive",
message: `Session timed out after ${INACTIVITY_TIMEOUT_MINUTES} minutes of inactivity`,
};
}
// Validate access token hash (optional security check)
if (session.access_token_hash) {
const provided_hash = hash_token(access_token);
if (session.access_token_hash !== provided_hash) {
return { valid: false, reason: "Token mismatch" };
}
}
// Check if user is still active
if (!session.users.is_active) {
await revoke_session(session_id);
return { valid: false, reason: "User inactive" };
}
return {
valid: true,
session,
user: session.users,
};
} catch (error) {
console.error("Error validating session:", error);
return { valid: false, reason: "Validation error" };
}
}
/**
* Update session activity timestamp
*/
async function update_session_activity(session_id) {
try {
await prisma.user_sessions.update({
where: { id: session_id },
data: { last_activity: new Date() },
});
return true;
} catch (error) {
console.error("Error updating session activity:", error);
return false;
}
}
/**
* Refresh access token using refresh token
*/
async function refresh_access_token(refresh_token) {
try {
const hashed_token = hash_token(refresh_token);
const session = await prisma.user_sessions.findUnique({
where: { refresh_token: hashed_token },
include: { users: true },
});
if (!session) {
return { success: false, error: "Invalid refresh token" };
}
// Validate session
const validation = await validate_session(session.id, "");
if (!validation.valid) {
return { success: false, error: validation.reason };
}
// Generate new access token
const new_access_token = generate_access_token(session.user_id, session.id);
// Update access token hash
await prisma.user_sessions.update({
where: { id: session.id },
data: {
access_token_hash: hash_token(new_access_token),
last_activity: new Date(),
},
});
return {
success: true,
access_token: new_access_token,
user: session.users,
};
} catch (error) {
console.error("Error refreshing access token:", error);
return { success: false, error: "Token refresh failed" };
}
}
/**
* Revoke a session
*/
async function revoke_session(session_id) {
try {
await prisma.user_sessions.update({
where: { id: session_id },
data: { is_revoked: true },
});
return true;
} catch (error) {
console.error("Error revoking session:", error);
return false;
}
}
/**
* Revoke all sessions for a user
*/
async function revoke_all_user_sessions(user_id) {
try {
await prisma.user_sessions.updateMany({
where: { user_id: user_id },
data: { is_revoked: true },
});
return true;
} catch (error) {
console.error("Error revoking user sessions:", error);
return false;
}
}
/**
* Clean up expired sessions (should be run periodically)
*/
async function cleanup_expired_sessions() {
try {
const result = await prisma.user_sessions.deleteMany({
where: {
OR: [{ expires_at: { lt: new Date() } }, { is_revoked: true }],
},
});
console.log(`Cleaned up ${result.count} expired sessions`);
return result.count;
} catch (error) {
console.error("Error cleaning up sessions:", error);
return 0;
}
}
/**
* Get active sessions for a user
*/
async function get_user_sessions(user_id) {
try {
return await prisma.user_sessions.findMany({
where: {
user_id: user_id,
is_revoked: false,
expires_at: { gt: new Date() },
},
select: {
id: true,
ip_address: true,
user_agent: true,
last_activity: true,
created_at: true,
expires_at: true,
},
orderBy: { last_activity: "desc" },
});
} catch (error) {
console.error("Error getting user sessions:", error);
return [];
}
}
module.exports = {
create_session,
validate_session,
update_session_activity,
refresh_access_token,
revoke_session,
revoke_all_user_sessions,
cleanup_expired_sessions,
get_user_sessions,
generate_access_token,
INACTIVITY_TIMEOUT_MINUTES,
};

View File

@@ -10,8 +10,8 @@ PatchMon is a containerised application that monitors system patches and updates
## Images
- **Backend**: [ghcr.io/9technologygroup/patchmon-backend:latest](https://github.com/9technologygroup/patchmon.net/pkgs/container/patchmon-backend)
- **Frontend**: [ghcr.io/9technologygroup/patchmon-frontend:latest](https://github.com/9technologygroup/patchmon.net/pkgs/container/patchmon-frontend)
- **Backend**: [ghcr.io/patchmon/patchmon-backend:latest](https://github.com/patchmon/patchmon.net/pkgs/container/patchmon-backend)
- **Frontend**: [ghcr.io/patchmon/patchmon-frontend:latest](https://github.com/patchmon/patchmon.net/pkgs/container/patchmon-frontend)
Version tags are also available (e.g. `1.2.3`) for both of these images.
@@ -20,26 +20,37 @@ Version tags are also available (e.g. `1.2.3`) for both of these images.
### Production Deployment
1. Download the [Docker Compose file](docker-compose.yml)
2. Change the default database password in the file:
2. Set a database password in the file where it says:
```yaml
environment:
POSTGRES_PASSWORD: YOUR_SECURE_PASSWORD_HERE
POSTGRES_PASSWORD: # CREATE A STRONG PASSWORD AND PUT IT HERE
```
3. Update the corresponding `DATABASE_URL` in the backend service:
3. Update the corresponding `DATABASE_URL` with your password in the backend service where it says:
```yaml
environment:
DATABASE_URL: postgresql://patchmon_user:YOUR_SECURE_PASSWORD_HERE@database:5432/patchmon_db
DATABASE_URL: postgresql://patchmon_user:REPLACE_YOUR_POSTGRES_PASSWORD_HERE@database:5432/patchmon_db
```
4. Configure environment variables (see [Configuration](#configuration) section)
5. Start the application:
4. Generate a strong JWT secret. You can do this like so:
```bash
openssl rand -hex 64
```
5. Set a JWT secret in the backend service where it says:
```yaml
environment:
JWT_SECRET: # CREATE A STRONG SECRET AND PUT IT HERE
```
6. Configure environment variables (see [Configuration](#configuration) section)
7. Start the application:
```bash
docker compose up -d
```
6. Access the application at `http://localhost:3000`
8. Access the application at `http://localhost:3000`
## Updating
To update PatchMon to the latest version:
By default, the compose file uses the `latest` tag for both backend and frontend images.
This means you can update PatchMon to the latest version as easily as:
```bash
docker compose up -d --pull
@@ -52,16 +63,18 @@ This command will:
### Version-Specific Updates
If you're using specific version tags instead of `latest` in your compose file:
If you'd like to pin your Docker deployment of PatchMon to a specific version, you can do this in the compose file.
1. Update the image tags in your `docker-compose.yml`. For example:
When you do this, updating to a new version requires manually updating the image tags in the compose file yourself:
1. Update the image tags in `docker-compose.yml`. For example:
```yaml
services:
backend:
image: ghcr.io/9technologygroup/patchmon-backend:1.2.7 # Update version here
image: ghcr.io/patchmon/patchmon-backend:1.2.3 # Update version here
...
frontend:
image: ghcr.io/9technologygroup/patchmon-frontend:1.2.7 # Update version here
image: ghcr.io/patchmon/patchmon-frontend:1.2.3 # Update version here
...
```
@@ -71,7 +84,7 @@ If you're using specific version tags instead of `latest` in your compose file:
```
> [!TIP]
> Check the [releases page](https://github.com/9technologygroup/patchmon.net/releases) for version-specific changes and migration notes.
> Check the [releases page](https://github.com/PatchMon/PatchMon/releases) for version-specific changes and migration notes.
## Configuration
@@ -79,31 +92,68 @@ If you're using specific version tags instead of `latest` in your compose file:
#### Database Service
- `POSTGRES_DB`: Database name (default: `patchmon_db`)
- `POSTGRES_USER`: Database user (default: `patchmon_user`)
- `POSTGRES_PASSWORD`: Database password - **MUST BE CHANGED!**
| Variable | Description | Default |
| ------------------- | ----------------- | ---------------- |
| `POSTGRES_DB` | Database name | `patchmon_db` |
| `POSTGRES_USER` | Database user | `patchmon_user` |
| `POSTGRES_PASSWORD` | Database password | **MUST BE SET!** |
#### Backend Service
- `LOG_LEVEL`: Logging level (`debug`, `info`, `warn`, `error`)
- `DATABASE_URL`: PostgreSQL connection string
- `PM_DB_CONN_MAX_ATTEMPTS`: Maximum database connection attempts (default: 30)
- `PM_DB_CONN_WAIT_INTERVAL`: Wait interval between connection attempts in seconds (default: 2)
- `SERVER_PROTOCOL`: Frontend server protocol (`http` or `https`)
- `SERVER_HOST`: Frontend server host (default: `localhost`)
- `SERVER_PORT`: Frontend server port (default: 3000)
- `PORT`: Backend API port (default: 3001)
- `API_VERSION`: API version (default: `v1`)
- `CORS_ORIGIN`: CORS origin URL
- `RATE_LIMIT_WINDOW_MS`: Rate limiting window in milliseconds (default: 900000)
- `RATE_LIMIT_MAX`: Maximum requests per window (default: 100)
- `ENABLE_HSTS`: Enable HTTP Strict Transport Security (default: true)
- `TRUST_PROXY`: Trust proxy headers (default: true) - See [Express.js docs](https://expressjs.com/en/guide/behind-proxies.html) for usage.
##### Database Configuration
| Variable | Description | Default |
| -------------------------- | ---------------------------------------------------- | ------------------------------------------------ |
| `DATABASE_URL` | PostgreSQL connection string | **MUST BE UPDATED WITH YOUR POSTGRES_PASSWORD!** |
| `PM_DB_CONN_MAX_ATTEMPTS` | Maximum database connection attempts | `30` |
| `PM_DB_CONN_WAIT_INTERVAL` | Wait interval between connection attempts in seconds | `2` |
##### Authentication & Security
| Variable | Description | Default |
| ------------------------------------ | --------------------------------------------------------- | ---------------- |
| `JWT_SECRET` | JWT signing secret - Generate with `openssl rand -hex 64` | **MUST BE SET!** |
| `JWT_EXPIRES_IN` | JWT token expiration time | `1h` |
| `JWT_REFRESH_EXPIRES_IN` | JWT refresh token expiration time | `7d` |
| `SESSION_INACTIVITY_TIMEOUT_MINUTES` | Session inactivity timeout in minutes | `30` |
| `DEFAULT_USER_ROLE` | Default role for new users | `user` |
##### Server & Network Configuration
| Variable | Description | Default |
| ----------------- | ----------------------------------------------------------------------------------------------- | ----------------------- |
| `PORT` | Backend API port | `3001` |
| `SERVER_PROTOCOL` | Frontend server protocol (`http` or `https`) | `http` |
| `SERVER_HOST` | Frontend server host | `localhost` |
| `SERVER_PORT` | Frontend server port | `3000` |
| `CORS_ORIGIN` | CORS origin URL | `http://localhost:3000` |
| `ENABLE_HSTS` | Enable HTTP Strict Transport Security | `true` |
| `TRUST_PROXY` | Trust proxy headers - See [Express.js docs](https://expressjs.com/en/guide/behind-proxies.html) | `true` |
##### Rate Limiting
| Variable | Description | Default |
| ---------------------------- | --------------------------------------------------- | -------- |
| `RATE_LIMIT_WINDOW_MS` | Rate limiting window in milliseconds | `900000` |
| `RATE_LIMIT_MAX` | Maximum requests per window | `5000` |
| `AUTH_RATE_LIMIT_WINDOW_MS` | Authentication rate limiting window in milliseconds | `600000` |
| `AUTH_RATE_LIMIT_MAX` | Maximum authentication requests per window | `500` |
| `AGENT_RATE_LIMIT_WINDOW_MS` | Agent API rate limiting window in milliseconds | `60000` |
| `AGENT_RATE_LIMIT_MAX` | Maximum agent requests per window | `1000` |
##### Logging
| Variable | Description | Default |
| ---------------- | ------------------------------------------------ | ------- |
| `LOG_LEVEL` | Logging level (`debug`, `info`, `warn`, `error`) | `info` |
| `ENABLE_LOGGING` | Enable application logging | `true` |
#### Frontend Service
- `BACKEND_HOST`: Backend service hostname (default: `backend`)
- `BACKEND_PORT`: Backend service port (default: 3001)
| Variable | Description | Default |
| -------------- | ------------------------ | --------- |
| `BACKEND_HOST` | Backend service hostname | `backend` |
| `BACKEND_PORT` | Backend service port | `3001` |
### Volumes
@@ -129,7 +179,7 @@ For development with live reload and source code mounting:
1. Clone the repository:
```bash
git clone https://github.com/9technologygroup/patchmon.net.git
git clone https://github.com/PatchMon/PatchMon.git
cd patchmon.net
```
@@ -203,7 +253,7 @@ The development setup exposes additional ports for debugging:
1. **Initial Setup**: Clone repository and start development environment
```bash
git clone https://github.com/9technologygroup/patchmon.net.git
git clone https://github.com/PatchMon/PatchMon.git
cd patchmon.net
docker compose -f docker/docker-compose.dev.yml up -d --build
```

View File

@@ -59,7 +59,10 @@ ENV NODE_ENV=production \
ENABLE_LOGGING=true \
LOG_LEVEL=info \
PM_LOG_TO_CONSOLE=true \
PORT=3001
PORT=3001 \
JWT_EXPIRES_IN=1h \
JWT_REFRESH_EXPIRES_IN=7d \
SESSION_INACTIVITY_TIMEOUT_MINUTES=30
RUN apk add --no-cache openssl tini curl

View File

@@ -1,3 +1,5 @@
name: patchmon-dev
services:
database:
image: postgres:17-alpine
@@ -5,7 +7,7 @@ services:
environment:
POSTGRES_DB: patchmon_db
POSTGRES_USER: patchmon_user
POSTGRES_PASSWORD: INSECURE_REPLACE_ME_PLEASE_INSECURE
POSTGRES_PASSWORD: 1NS3CU6E_DEV_D8_PASSW0RD
ports:
- "5432:5432"
volumes:
@@ -21,19 +23,17 @@ services:
context: ..
dockerfile: docker/backend.Dockerfile
target: development
tags: [patchmon-backend:dev]
restart: unless-stopped
environment:
NODE_ENV: development
LOG_LEVEL: info
DATABASE_URL: postgresql://patchmon_user:INSECURE_REPLACE_ME_PLEASE_INSECURE@database:5432/patchmon_db
PM_DB_CONN_MAX_ATTEMPTS: 30
PM_DB_CONN_WAIT_INTERVAL: 2
DATABASE_URL: postgresql://patchmon_user:1NS3CU6E_DEV_D8_PASSW0RD@database:5432/patchmon_db
JWT_SECRET: INS3CURE_DEV_7WT_5ECR3T
SERVER_PROTOCOL: http
SERVER_HOST: localhost
SERVER_PORT: 3000
CORS_ORIGIN: http://localhost:3000
RATE_LIMIT_WINDOW_MS: 900000
RATE_LIMIT_MAX: 100
ports:
- "3001:3001"
volumes:
@@ -59,6 +59,7 @@ services:
context: ..
dockerfile: docker/frontend.Dockerfile
target: development
tags: [patchmon-frontend:dev]
restart: unless-stopped
environment:
BACKEND_HOST: backend

View File

@@ -1,3 +1,5 @@
name: patchmon
services:
database:
image: postgres:17-alpine
@@ -5,7 +7,7 @@ services:
environment:
POSTGRES_DB: patchmon_db
POSTGRES_USER: patchmon_user
POSTGRES_PASSWORD: INSECURE_REPLACE_ME_PLEASE_INSECURE
POSTGRES_PASSWORD: # CREATE A STRONG PASSWORD AND PUT IT HERE
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
@@ -15,19 +17,17 @@ services:
retries: 7
backend:
image: ghcr.io/9technologygroup/patchmon-backend:latest
image: ghcr.io/patchmon/patchmon-backend:latest
restart: unless-stopped
# See PatchMon Docker README for additional environment variables and configuration instructions
environment:
LOG_LEVEL: info
DATABASE_URL: postgresql://patchmon_user:INSECURE_REPLACE_ME_PLEASE_INSECURE@database:5432/patchmon_db
PM_DB_CONN_MAX_ATTEMPTS: 30
PM_DB_CONN_WAIT_INTERVAL: 2
DATABASE_URL: postgresql://patchmon_user:REPLACE_YOUR_POSTGRES_PASSWORD_HERE@database:5432/patchmon_db
JWT_SECRET: # CREATE A STRONG SECRET AND PUT IT HERE - Generate with 'openssl rand -hex 64'
SERVER_PROTOCOL: http
SERVER_HOST: localhost
SERVER_PORT: 3000
CORS_ORIGIN: http://localhost:3000
RATE_LIMIT_WINDOW_MS: 900000
RATE_LIMIT_MAX: 100
volumes:
- agent_files:/app/agents
depends_on:
@@ -35,7 +35,7 @@ services:
condition: service_healthy
frontend:
image: ghcr.io/9technologygroup/patchmon-frontend:latest
image: ghcr.io/patchmon/patchmon-frontend:latest
restart: unless-stopped
ports:
- "3000:3000"

View File

@@ -2,6 +2,7 @@ import { Route, Routes } from "react-router-dom";
import FirstTimeAdminSetup from "./components/FirstTimeAdminSetup";
import Layout from "./components/Layout";
import ProtectedRoute from "./components/ProtectedRoute";
import SettingsLayout from "./components/SettingsLayout";
import { isAuthPhase } from "./constants/authPhases";
import { AuthProvider, useAuth } from "./contexts/AuthContext";
import { ThemeProvider } from "./contexts/ThemeContext";
@@ -10,15 +11,19 @@ import Dashboard from "./pages/Dashboard";
import HostDetail from "./pages/HostDetail";
import Hosts from "./pages/Hosts";
import Login from "./pages/Login";
import Options from "./pages/Options";
import PackageDetail from "./pages/PackageDetail";
import Packages from "./pages/Packages";
import Permissions from "./pages/Permissions";
import Profile from "./pages/Profile";
import Repositories from "./pages/Repositories";
import RepositoryDetail from "./pages/RepositoryDetail";
import Settings from "./pages/Settings";
import Users from "./pages/Users";
import AlertChannels from "./pages/settings/AlertChannels";
import Integrations from "./pages/settings/Integrations";
import Notifications from "./pages/settings/Notifications";
import PatchManagement from "./pages/settings/PatchManagement";
import SettingsAgentConfig from "./pages/settings/SettingsAgentConfig";
import SettingsHostGroups from "./pages/settings/SettingsHostGroups";
import SettingsServerConfig from "./pages/settings/SettingsServerConfig";
import SettingsUsers from "./pages/settings/SettingsUsers";
function AppRoutes() {
const { needsFirstTimeSetup, authPhase, isAuthenticated } = useAuth();
@@ -114,7 +119,7 @@ function AppRoutes() {
element={
<ProtectedRoute requirePermission="can_view_users">
<Layout>
<Users />
<SettingsUsers />
</Layout>
</ProtectedRoute>
}
@@ -124,7 +129,7 @@ function AppRoutes() {
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<Permissions />
<SettingsUsers />
</Layout>
</ProtectedRoute>
}
@@ -134,7 +139,163 @@ function AppRoutes() {
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<Settings />
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/users"
element={
<ProtectedRoute requirePermission="can_view_users">
<Layout>
<SettingsUsers />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/roles"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsUsers />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/profile"
element={
<ProtectedRoute>
<Layout>
<SettingsLayout>
<Profile />
</SettingsLayout>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/host-groups"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsHostGroups />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/notifications"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsLayout>
<Notifications />
</SettingsLayout>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/agent-config"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsAgentConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/agent-config/management"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsAgentConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/server-config"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/server-config/version"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/alert-channels"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsLayout>
<AlertChannels />
</SettingsLayout>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/integrations"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<Integrations />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/patch-management"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<PatchManagement />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/server-url"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/server-version"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/agent-version"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsAgentConfig />
</Layout>
</ProtectedRoute>
}
@@ -144,17 +305,7 @@ function AppRoutes() {
element={
<ProtectedRoute requirePermission="can_manage_hosts">
<Layout>
<Options />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<Layout>
<Profile />
<SettingsHostGroups />
</Layout>
</ProtectedRoute>
}

View File

@@ -6,7 +6,6 @@ import {
ChevronRight,
Clock,
Container,
FileText,
GitBranch,
Github,
Globe,
@@ -23,8 +22,6 @@ import {
Shield,
Star,
UserCircle,
Users,
Wrench,
X,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
@@ -141,69 +138,41 @@ const Layout = ({ children }) => {
}
}
// PatchMon Users section - only show if user can view/manage users
if (canViewUsers() || canManageUsers()) {
const userItems = [];
if (canViewUsers()) {
userItems.push({ name: "Users", href: "/users", icon: Users });
}
if (canManageSettings()) {
userItems.push({
name: "Permissions",
href: "/permissions",
icon: Shield,
});
}
if (userItems.length > 0) {
nav.push({
section: "PatchMon Users",
items: userItems,
});
}
}
// Settings section - only show if user has any settings permissions
if (canManageSettings() || canViewReports() || canExportData()) {
const settingsItems = [];
if (canManageSettings()) {
settingsItems.push({
name: "PatchMon Options",
href: "/options",
icon: Settings,
});
settingsItems.push({
name: "Server Config",
href: "/settings",
icon: Wrench,
showUpgradeIcon: updateAvailable,
});
}
if (canViewReports() || canExportData()) {
settingsItems.push({
name: "Audit Log",
href: "/audit-log",
icon: FileText,
comingSoon: true,
});
}
if (settingsItems.length > 0) {
nav.push({
section: "Settings",
items: settingsItems,
});
}
}
return nav;
};
// Build settings navigation separately (for bottom placement)
const buildSettingsNavigation = () => {
const settingsNav = [];
// Settings section - consolidated all settings into one page
if (
canManageSettings() ||
canViewUsers() ||
canManageUsers() ||
canViewReports() ||
canExportData()
) {
const settingsItems = [];
settingsItems.push({
name: "Settings",
href: "/settings/users",
icon: Settings,
showUpgradeIcon: updateAvailable,
});
settingsNav.push({
section: "Settings",
items: settingsItems,
});
}
return settingsNav;
};
const navigation = buildNavigation();
const settingsNavigation = buildSettingsNavigation();
const isActive = (path) => location.pathname === path;
@@ -223,9 +192,10 @@ const Layout = ({ children }) => {
if (path === "/settings") return "Settings";
if (path === "/options") return "PatchMon Options";
if (path === "/audit-log") return "Audit Log";
if (path === "/profile") return "My Profile";
if (path === "/settings/profile") return "My Profile";
if (path.startsWith("/hosts/")) return "Host Details";
if (path.startsWith("/packages/")) return "Package Details";
if (path.startsWith("/settings/")) return "Settings";
return "PatchMon";
};
@@ -342,7 +312,7 @@ const Layout = ({ children }) => {
</div>
<nav className="mt-8 flex-1 space-y-6 px-2">
{/* Show message for users with very limited permissions */}
{navigation.length === 0 && (
{navigation.length === 0 && settingsNavigation.length === 0 && (
<div className="px-2 py-4 text-center">
<div className="text-sm text-secondary-500 dark:text-secondary-400">
<p className="mb-2">Limited access</p>
@@ -394,6 +364,12 @@ const Layout = ({ children }) => {
<subItem.icon className="mr-3 h-5 w-5" />
<span className="flex items-center gap-2 flex-1">
{subItem.name}
{subItem.name === "Hosts" &&
stats?.cards?.totalHosts !== undefined && (
<span className="ml-2 inline-flex items-center justify-center px-1.5 py-0.5 text-xs rounded bg-secondary-100 text-secondary-700">
{stats.cards.totalHosts}
</span>
)}
</span>
<button
type="button"
@@ -426,6 +402,12 @@ const Layout = ({ children }) => {
<subItem.icon className="mr-3 h-5 w-5" />
<span className="flex items-center gap-2">
{subItem.name}
{subItem.name === "Hosts" &&
stats?.cards?.totalHosts !== undefined && (
<span className="ml-2 inline-flex items-center justify-center px-1.5 py-0.5 text-xs rounded bg-secondary-100 text-secondary-700">
{stats.cards.totalHosts}
</span>
)}
{subItem.comingSoon && (
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
Soon
@@ -442,6 +424,40 @@ const Layout = ({ children }) => {
}
return null;
})}
{/* Settings Section - Mobile */}
{settingsNavigation.map((item) => {
if (item.section) {
// Settings section (no heading)
return (
<div key={item.section}>
<div className="space-y-1">
{item.items.map((subItem) => (
<Link
key={subItem.name}
to={subItem.href}
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
isActive(subItem.href)
? "bg-primary-100 text-primary-900"
: "text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900"
}`}
onClick={() => setSidebarOpen(false)}
>
<subItem.icon className="mr-3 h-5 w-5" />
<span className="flex items-center gap-2">
{subItem.name}
{subItem.showUpgradeIcon && (
<UpgradeNotificationIcon className="h-3 w-3" />
)}
</span>
</Link>
))}
</div>
</div>
);
}
return null;
})}
</nav>
</div>
</div>
@@ -493,7 +509,7 @@ const Layout = ({ children }) => {
<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 */}
{navigation.length === 0 && (
{navigation.length === 0 && settingsNavigation.length === 0 && (
<li className="px-2 py-4 text-center">
<div className="text-sm text-secondary-500 dark:text-secondary-400">
<p className="mb-2">Limited access</p>
@@ -558,6 +574,13 @@ const Layout = ({ children }) => {
{!sidebarCollapsed && (
<span className="truncate flex items-center gap-2 flex-1">
{subItem.name}
{subItem.name === "Hosts" &&
stats?.cards?.totalHosts !==
undefined && (
<span className="ml-2 inline-flex items-center justify-center px-1.5 py-0.5 text-xs rounded bg-secondary-100 text-secondary-700">
{stats.cards.totalHosts}
</span>
)}
</span>
)}
{!sidebarCollapsed && (
@@ -609,6 +632,13 @@ const Layout = ({ children }) => {
{!sidebarCollapsed && (
<span className="truncate flex items-center gap-2">
{subItem.name}
{subItem.name === "Hosts" &&
stats?.cards?.totalHosts !==
undefined && (
<span className="ml-2 inline-flex items-center justify-center px-1.5 py-0.5 text-xs rounded bg-secondary-100 text-secondary-700">
{stats.cards.totalHosts}
</span>
)}
{subItem.comingSoon && (
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
Soon
@@ -630,6 +660,59 @@ const Layout = ({ children }) => {
return null;
})}
</ul>
{/* Settings Section - Bottom of Navigation */}
{settingsNavigation.length > 0 && (
<ul className="gap-y-6">
{settingsNavigation.map((item) => {
if (item.section) {
// Settings section (no heading)
return (
<li key={item.section}>
<ul
className={`space-y-1 ${sidebarCollapsed ? "" : "-mx-2"}`}
>
{item.items.map((subItem) => (
<li key={subItem.name}>
<Link
to={subItem.href}
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-medium transition-all duration-200 ${
isActive(subItem.href)
? "bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white"
: "text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700"
} ${sidebarCollapsed ? "justify-center p-2 relative" : "p-2"}`}
title={sidebarCollapsed ? subItem.name : ""}
>
<div
className={`flex items-center ${sidebarCollapsed ? "justify-center" : ""}`}
>
<subItem.icon
className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? "mx-auto" : ""}`}
/>
{sidebarCollapsed &&
subItem.showUpgradeIcon && (
<UpgradeNotificationIcon className="h-3 w-3 absolute -top-1 -right-1" />
)}
</div>
{!sidebarCollapsed && (
<span className="truncate flex items-center gap-2">
{subItem.name}
{subItem.showUpgradeIcon && (
<UpgradeNotificationIcon className="h-3 w-3" />
)}
</span>
)}
</Link>
</li>
))}
</ul>
</li>
);
}
return null;
})}
</ul>
)}
</nav>
{/* Profile Section - Bottom of Sidebar */}
@@ -637,11 +720,11 @@ const Layout = ({ children }) => {
{!sidebarCollapsed ? (
<div>
{/* User Info with Sign Out - Username is clickable */}
<div className="flex items-center justify-between p-2">
<div className="flex items-center justify-between -mx-2 py-2">
<Link
to="/profile"
to="/settings/profile"
className={`flex-1 min-w-0 rounded-md p-2 transition-all duration-200 ${
isActive("/profile")
isActive("/settings/profile")
? "bg-primary-50 dark:bg-primary-600"
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
}`}
@@ -649,15 +732,15 @@ const Layout = ({ children }) => {
<div className="flex items-center gap-x-3">
<UserCircle
className={`h-5 w-5 shrink-0 ${
isActive("/profile")
isActive("/settings/profile")
? "text-primary-700 dark:text-white"
: "text-secondary-500 dark:text-secondary-400"
}`}
/>
<div className="flex items-center gap-x-2">
<div className="flex flex-col min-w-0">
<span
className={`text-sm leading-6 font-semibold truncate ${
isActive("/profile")
isActive("/settings/profile")
? "text-primary-700 dark:text-white"
: "text-secondary-700 dark:text-secondary-200"
}`}
@@ -665,8 +748,14 @@ const Layout = ({ children }) => {
{user?.first_name || user?.username}
</span>
{user?.role === "admin" && (
<span className="inline-flex items-center rounded-full bg-primary-100 px-1.5 py-0.5 text-xs font-medium text-primary-800">
Admin
<span
className={`text-xs leading-4 ${
isActive("/settings/profile")
? "text-primary-600 dark:text-primary-200"
: "text-secondary-500 dark:text-secondary-400"
}`}
>
Role: Admin
</span>
)}
</div>
@@ -712,9 +801,9 @@ const Layout = ({ children }) => {
) : (
<div className="space-y-1">
<Link
to="/profile"
to="/settings/profile"
className={`flex items-center justify-center p-2 rounded-md transition-colors ${
isActive("/profile")
isActive("/settings/profile")
? "bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white"
: "text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700"
}`}

View File

@@ -0,0 +1,273 @@
import {
Bell,
ChevronLeft,
ChevronRight,
Code,
Folder,
RefreshCw,
Settings,
Shield,
UserCircle,
Users,
Wrench,
} from "lucide-react";
import { useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
const SettingsLayout = ({ children }) => {
const location = useLocation();
const { canManageSettings, canViewUsers, canManageUsers } = useAuth();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// Build secondary navigation based on permissions
const buildSecondaryNavigation = () => {
const nav = [];
// Users section
if (canViewUsers() || canManageUsers()) {
nav.push({
section: "User Management",
items: [
{
name: "Users",
href: "/settings/users",
icon: Users,
},
{
name: "Roles",
href: "/settings/roles",
icon: Shield,
},
{
name: "My Profile",
href: "/settings/profile",
icon: UserCircle,
},
],
});
}
// Host Groups
if (canManageSettings()) {
nav.push({
section: "Hosts Management",
items: [
{
name: "Host Groups",
href: "/settings/host-groups",
icon: Folder,
},
{
name: "Agent Updates",
href: "/settings/agent-config",
icon: RefreshCw,
},
{
name: "Agent Version",
href: "/settings/agent-version",
icon: Settings,
},
],
});
}
// Alert Management
if (canManageSettings()) {
nav.push({
section: "Alert Management",
items: [
{
name: "Alert Channels",
href: "/settings/alert-channels",
icon: Bell,
},
{
name: "Notifications",
href: "/settings/notifications",
icon: Bell,
comingSoon: true,
},
],
});
}
// Patch Management
if (canManageSettings()) {
nav.push({
section: "Patch Management",
items: [
{
name: "Policies",
href: "/settings/patch-management",
icon: Settings,
comingSoon: true,
},
],
});
}
// Server Config
if (canManageSettings()) {
// Integrations section
nav.push({
section: "Integrations",
items: [
{
name: "Integrations",
href: "/settings/integrations",
icon: Wrench,
comingSoon: true,
},
],
});
nav.push({
section: "Server",
items: [
{
name: "URL Config",
href: "/settings/server-url",
icon: Wrench,
},
{
name: "Server Version",
href: "/settings/server-version",
icon: Code,
},
],
});
}
return nav;
};
const secondaryNavigation = buildSecondaryNavigation();
const isActive = (path) => location.pathname === path;
const _getPageTitle = () => {
const path = location.pathname;
if (path.startsWith("/settings/users")) return "Users";
if (path.startsWith("/settings/host-groups")) return "Host Groups";
if (path.startsWith("/settings/notifications")) return "Notifications";
if (path.startsWith("/settings/agent-config")) return "Agent Config";
if (path.startsWith("/settings/server-config")) return "Server Config";
return "Settings";
};
return (
<div className="bg-transparent">
{/* Within-page secondary navigation and content */}
<div className="px-2 sm:px-4 lg:px-6">
<div className="flex gap-4">
{/* Left secondary nav (within page) */}
<aside
className={`${sidebarCollapsed ? "w-14" : "w-56"} transition-all duration-300 flex-shrink-0`}
>
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg">
{/* Collapse button */}
<div className="flex justify-end p-2 border-b border-secondary-200 dark:border-secondary-600">
<button
type="button"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="p-1 text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300 rounded transition-colors"
title={
sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"
}
>
{sidebarCollapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</button>
</div>
<div className={`${sidebarCollapsed ? "p-2" : "p-3"}`}>
<nav>
<ul
className={`${sidebarCollapsed ? "space-y-2" : "space-y-4"}`}
>
{secondaryNavigation.map((item) => (
<li key={item.section}>
{!sidebarCollapsed && (
<h4 className="text-xs font-semibold text-secondary-500 dark:text-secondary-300 uppercase tracking-wider mb-2">
{item.section}
</h4>
)}
<ul
className={`${sidebarCollapsed ? "space-y-1" : "space-y-1"}`}
>
{item.items.map((subItem) => (
<li key={subItem.name}>
<Link
to={subItem.href}
className={`group flex items-center rounded-md text-sm leading-5 font-medium transition-colors ${
sidebarCollapsed
? "justify-center p-2"
: "gap-2 p-2"
} ${
isActive(subItem.href)
? "bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white"
: "text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700"
}`}
title={sidebarCollapsed ? subItem.name : ""}
>
<subItem.icon className="h-4 w-4 flex-shrink-0" />
{!sidebarCollapsed && (
<span className="truncate flex items-center gap-2">
{subItem.name}
{subItem.comingSoon && (
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
Soon
</span>
)}
</span>
)}
</Link>
{!sidebarCollapsed && subItem.subTabs && (
<ul className="ml-6 mt-1 space-y-1">
{subItem.subTabs.map((subTab) => (
<li key={subTab.name}>
<Link
to={subTab.href}
className={`block px-3 py-1 text-xs font-medium rounded transition-colors ${
isActive(subTab.href)
? "bg-primary-100 dark:bg-primary-700 text-primary-700 dark:text-primary-200"
: "text-secondary-600 dark:text-secondary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700"
}`}
>
{subTab.name}
</Link>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</li>
))}
</ul>
</nav>
</div>
</div>
</aside>
{/* Right content */}
<section className="flex-1 min-w-0">
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-4">
{children}
</div>
</section>
</div>
</div>
</div>
);
};
export default SettingsLayout;

View File

@@ -0,0 +1,379 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertCircle, Code, Download, Plus, Shield, X } from "lucide-react";
import { useId, useState } from "react";
import { agentFileAPI, settingsAPI } from "../../utils/api";
const AgentManagementTab = () => {
const scriptFileId = useId();
const scriptContentId = useId();
const [showUploadModal, setShowUploadModal] = useState(false);
// Agent file queries and mutations
const {
data: agentFileInfo,
isLoading: agentFileLoading,
error: agentFileError,
refetch: refetchAgentFile,
} = useQuery({
queryKey: ["agentFile"],
queryFn: () => agentFileAPI.getInfo().then((res) => res.data),
});
// Fetch settings for dynamic curl flags
const { data: settings } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Helper function to get curl flags based on settings
const getCurlFlags = () => {
return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
};
const uploadAgentMutation = useMutation({
mutationFn: (scriptContent) =>
agentFileAPI.upload(scriptContent).then((res) => res.data),
onSuccess: () => {
refetchAgentFile();
setShowUploadModal(false);
},
onError: (error) => {
console.error("Upload agent error:", error);
},
});
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<div className="flex items-center mb-2">
<Code className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
Agent File Management
</h2>
</div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">
Manage the PatchMon agent script file used for installations and
updates
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
const url = "/api/v1/hosts/agent/download";
const link = document.createElement("a");
link.href = url;
link.download = "patchmon-agent.sh";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
className="btn-outline flex items-center gap-2"
>
<Download className="h-4 w-4" />
Download
</button>
<button
type="button"
onClick={() => setShowUploadModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Replace Script
</button>
</div>
</div>
{/* Content */}
{agentFileLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
) : agentFileError ? (
<div className="text-center py-8">
<p className="text-red-600 dark:text-red-400">
Error loading agent file: {agentFileError.message}
</p>
</div>
) : !agentFileInfo?.exists ? (
<div className="text-center py-8">
<Code className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
No agent script found
</p>
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
Upload an agent script to get started
</p>
</div>
) : (
<div className="space-y-6">
{/* Agent File Info */}
<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">
Current Agent Script
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="flex items-center gap-2">
<Code 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">
Version:
</span>
<span className="text-sm text-secondary-900 dark:text-white font-mono">
{agentFileInfo.version}
</span>
</div>
<div className="flex items-center gap-2">
<Download 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">
Size:
</span>
<span className="text-sm text-secondary-900 dark:text-white">
{agentFileInfo.sizeFormatted}
</span>
</div>
<div className="flex items-center gap-2">
<Code className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Modified:
</span>
<span className="text-sm text-secondary-900 dark:text-white">
{new Date(agentFileInfo.lastModified).toLocaleDateString()}
</span>
</div>
</div>
</div>
{/* Usage Instructions */}
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
<div className="flex">
<Shield 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">
Agent Script Usage
</h3>
<div className="mt-2 text-sm text-blue-700 dark:text-blue-300">
<p className="mb-2">This script is used for:</p>
<ul className="list-disc list-inside space-y-1">
<li>New agent installations via the install script</li>
<li>
Agent downloads from the /api/v1/hosts/agent/download
endpoint
</li>
<li>Manual agent deployments and updates</li>
</ul>
</div>
</div>
</div>
</div>
{/* Uninstall Instructions */}
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<Shield 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">
Agent Uninstall Command
</h3>
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
<p className="mb-2">
To completely remove PatchMon from a host:
</p>
<div className="flex items-center gap-2">
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
curl {getCurlFlags()} {window.location.origin}
/api/v1/hosts/remove | sudo bash
</div>
<button
type="button"
onClick={() => {
const command = `curl ${getCurlFlags()} ${window.location.origin}/api/v1/hosts/remove | sudo bash`;
navigator.clipboard.writeText(command);
// You could add a toast notification here
}}
className="px-2 py-1 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors"
>
Copy
</button>
</div>
<p className="mt-2 text-xs">
This will remove all PatchMon files, configuration, and
crontab entries
</p>
</div>
</div>
</div>
</div>
</div>
)}
{/* Agent Upload Modal */}
{showUploadModal && (
<AgentUploadModal
isOpen={showUploadModal}
onClose={() => setShowUploadModal(false)}
onSubmit={uploadAgentMutation.mutate}
isLoading={uploadAgentMutation.isPending}
error={uploadAgentMutation.error}
scriptFileId={scriptFileId}
scriptContentId={scriptContentId}
/>
)}
</div>
);
};
// Agent Upload Modal Component
const AgentUploadModal = ({
isOpen,
onClose,
onSubmit,
isLoading,
error,
scriptFileId,
scriptContentId,
}) => {
const [scriptContent, setScriptContent] = useState("");
const [uploadError, setUploadError] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
setUploadError("");
if (!scriptContent.trim()) {
setUploadError("Script content is required");
return;
}
if (!scriptContent.trim().startsWith("#!/")) {
setUploadError(
"Script must start with a shebang (#!/bin/bash or #!/bin/sh)",
);
return;
}
onSubmit(scriptContent);
};
const handleFileUpload = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
setScriptContent(event.target.result);
setUploadError("");
};
reader.readAsText(file);
}
};
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-4xl 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">
Replace Agent Script
</h3>
<button
type="button"
onClick={onClose}
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
htmlFor={scriptFileId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Upload Script File
</label>
<input
id={scriptFileId}
type="file"
accept=".sh"
onChange={handleFileUpload}
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"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Select a .sh file to upload, or paste the script content below
</p>
</div>
<div>
<label
htmlFor={scriptContentId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Script Content *
</label>
<textarea
id={scriptContentId}
value={scriptContent}
onChange={(e) => {
setScriptContent(e.target.value);
setUploadError("");
}}
rows={15}
className="block 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="#!/bin/bash&#10;&#10;# PatchMon Agent Script&#10;VERSION=&quot;1.0.0&quot;&#10;&#10;# Your script content here..."
/>
</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 agent script file</li>
<li>A backup will be created automatically</li>
<li>All new installations will use this script</li>
<li>
Existing agents will download this version on their next
update
</li>
</ul>
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button type="button" onClick={onClose} className="btn-outline">
Cancel
</button>
<button
type="submit"
disabled={isLoading || !scriptContent.trim()}
className="btn-primary"
>
{isLoading ? "Uploading..." : "Replace Script"}
</button>
</div>
</form>
</div>
</div>
);
};
export default AgentManagementTab;

View File

@@ -0,0 +1,453 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, CheckCircle, Save, Shield } from "lucide-react";
import { useEffect, useId, useState } from "react";
import { permissionsAPI, settingsAPI } from "../../utils/api";
const AgentUpdatesTab = () => {
const updateIntervalId = useId();
const autoUpdateId = useId();
const signupEnabledId = useId();
const defaultRoleId = useId();
const ignoreSslId = useId();
const [formData, setFormData] = useState({
updateInterval: 60,
autoUpdate: false,
signupEnabled: false,
defaultUserRole: "user",
ignoreSslSelfSigned: false,
});
const [errors, setErrors] = useState({});
const [isDirty, setIsDirty] = useState(false);
const queryClient = useQueryClient();
// Fetch current settings
const {
data: settings,
isLoading,
error,
} = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Fetch available roles for default user role dropdown
const { data: roles, isLoading: rolesLoading } = useQuery({
queryKey: ["rolePermissions"],
queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
});
// Update form data when settings are loaded
useEffect(() => {
if (settings) {
const newFormData = {
updateInterval: settings.update_interval || 60,
autoUpdate: settings.auto_update || false,
signupEnabled: settings.signup_enabled === true,
defaultUserRole: settings.default_user_role || "user",
ignoreSslSelfSigned: settings.ignore_ssl_self_signed === true,
};
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",
});
}
},
});
// Normalize update interval to safe presets
const normalizeInterval = (minutes) => {
let m = parseInt(minutes, 10);
if (Number.isNaN(m)) return 60;
if (m < 5) m = 5;
if (m > 1440) m = 1440;
// If less than 60 minutes, keep within 5-59 and step of 5
if (m < 60) {
return Math.min(59, Math.max(5, Math.round(m / 5) * 5));
}
// 60 or more: only allow exact hour multiples (60, 120, 180, 360, 720, 1440)
const allowed = [60, 120, 180, 360, 720, 1440];
// Snap to nearest allowed value
let nearest = allowed[0];
let bestDiff = Math.abs(m - nearest);
for (const a of allowed) {
const d = Math.abs(m - a);
if (d < bestDiff) {
bestDiff = d;
nearest = a;
}
}
return nearest;
};
const handleInputChange = (field, value) => {
setFormData((prev) => {
const newData = {
...prev,
[field]: field === "updateInterval" ? normalizeInterval(value) : value,
};
return newData;
});
setIsDirty(true);
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: null }));
}
};
const validateForm = () => {
const newErrors = {};
if (
!formData.updateInterval ||
formData.updateInterval < 5 ||
formData.updateInterval > 1440
) {
newErrors.updateInterval =
"Update interval must be between 5 and 1440 minutes";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = () => {
if (validateForm()) {
updateSettingsMutation.mutate(formData);
}
};
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">
{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>
)}
<form className="space-y-6">
{/* Update Interval */}
<div>
<label
htmlFor={updateIntervalId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Agent Update Interval (minutes)
</label>
{/* Numeric input (concise width) */}
<div className="flex items-center gap-2">
<input
id={updateIntervalId}
type="number"
min="5"
max="1440"
step="5"
value={formData.updateInterval}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!Number.isNaN(val)) {
handleInputChange(
"updateInterval",
Math.min(1440, Math.max(5, val)),
);
} else {
handleInputChange("updateInterval", 60);
}
}}
className={`w-28 border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
errors.updateInterval
? "border-red-300 dark:border-red-500"
: "border-secondary-300 dark:border-secondary-600"
}`}
placeholder="60"
/>
</div>
{/* Quick presets */}
<div className="mt-3 flex flex-wrap items-center gap-2">
{[5, 10, 15, 30, 45, 60, 120, 180, 360, 720, 1440].map((m) => (
<button
key={m}
type="button"
onClick={() => handleInputChange("updateInterval", m)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border ${
formData.updateInterval === m
? "bg-primary-600 text-white border-primary-600"
: "bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 border-secondary-300 dark:border-secondary-600 hover:bg-secondary-50 dark:hover:bg-secondary-600"
}`}
aria-label={`Set ${m} minutes`}
>
{m % 60 === 0 ? `${m / 60}h` : `${m}m`}
</button>
))}
</div>
{/* Range slider */}
<div className="mt-4">
<input
type="range"
min="5"
max="1440"
step="5"
value={formData.updateInterval}
onChange={(e) => {
const raw = parseInt(e.target.value, 10);
handleInputChange("updateInterval", normalizeInterval(raw));
}}
className="w-auto accent-primary-600"
aria-label="Update interval slider"
style={{ width: "fit-content", minWidth: "500px" }}
/>
</div>
{errors.updateInterval && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
{errors.updateInterval}
</p>
)}
{/* Helper text */}
<div className="mt-2 text-sm text-secondary-600 dark:text-secondary-300">
<span className="font-medium">Effective cadence:</span> {(() => {
const mins = parseInt(formData.updateInterval, 10) || 60;
if (mins < 60) return `${mins} minute${mins === 1 ? "" : "s"}`;
const hrs = Math.floor(mins / 60);
const rem = mins % 60;
return `${hrs} hour${hrs === 1 ? "" : "s"}${rem ? ` ${rem} min` : ""}`;
})()}
</div>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
This affects new installations and will update existing ones when
they next reach out.
</p>
</div>
{/* Auto-Update Setting */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
<div className="flex items-center gap-2">
<input
id={autoUpdateId}
type="checkbox"
checked={formData.autoUpdate}
onChange={(e) =>
handleInputChange("autoUpdate", e.target.checked)
}
className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
/>
<label htmlFor={autoUpdateId}>
Enable Automatic Agent Updates
</label>
</div>
</label>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
When enabled, agents will automatically update themselves when a
newer version is available during their regular update cycle.
</p>
</div>
{/* SSL Certificate Setting */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
<div className="flex items-center gap-2">
<input
id={ignoreSslId}
type="checkbox"
checked={formData.ignoreSslSelfSigned}
onChange={(e) =>
handleInputChange("ignoreSslSelfSigned", e.target.checked)
}
className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
/>
<label htmlFor={ignoreSslId}>
Ignore SSL Self-Signed Certificates
</label>
</div>
</label>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
When enabled, curl commands in agent scripts will use the -k flag to
ignore SSL certificate validation errors. Use with caution on
production systems as this reduces security.
</p>
</div>
{/* User Signup Setting */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
<div className="flex items-center gap-2">
<input
id={signupEnabledId}
type="checkbox"
checked={formData.signupEnabled}
onChange={(e) =>
handleInputChange("signupEnabled", e.target.checked)
}
className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
/>
<label htmlFor={signupEnabledId}>
Enable User Self-Registration
</label>
</div>
</label>
{/* Default User Role Dropdown */}
{formData.signupEnabled && (
<div className="mt-3 ml-6">
<label
htmlFor={defaultRoleId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Default Role for New Users
</label>
<select
id={defaultRoleId}
value={formData.defaultUserRole}
onChange={(e) =>
handleInputChange("defaultUserRole", e.target.value)
}
className="w-full max-w-xs 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"
disabled={rolesLoading}
>
{rolesLoading ? (
<option>Loading roles...</option>
) : roles && Array.isArray(roles) ? (
roles.map((role) => (
<option key={role.role} value={role.role}>
{role.role.charAt(0).toUpperCase() + role.role.slice(1)}
</option>
))
) : (
<option value="user">User</option>
)}
</select>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
New users will be assigned this role when they register.
</p>
</div>
)}
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
When enabled, users can create their own accounts through the signup
page. When disabled, only administrators can create user accounts.
</p>
</div>
{/* Security Notice */}
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
<div className="flex">
<Shield 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">
Security Notice
</h3>
<p className="mt-1 text-sm text-blue-700 dark:text-blue-300">
When enabling user self-registration, exercise caution on
internal networks. Consider restricting access to trusted
networks only and ensure proper role assignments to prevent
unauthorized access to sensitive systems.
</p>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<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>
{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>
</div>
</div>
)}
</form>
</div>
);
};
export default AgentUpdatesTab;

View File

@@ -0,0 +1,305 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, CheckCircle, Save, Server, Shield } from "lucide-react";
import { useEffect, useId, useState } from "react";
import { settingsAPI } from "../../utils/api";
const ProtocolUrlTab = () => {
const protocolId = useId();
const hostId = useId();
const portId = useId();
const [formData, setFormData] = useState({
serverProtocol: "http",
serverHost: "localhost",
serverPort: 3001,
});
const [errors, setErrors] = useState({});
const [isDirty, setIsDirty] = useState(false);
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 = {
serverProtocol: settings.server_protocol || "http",
serverHost: settings.server_host || "localhost",
serverPort: settings.server_port || 3001,
};
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",
});
}
},
});
const handleInputChange = (field, value) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
setIsDirty(true);
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: null }));
}
};
const validateForm = () => {
const newErrors = {};
if (!formData.serverHost.trim()) {
newErrors.serverHost = "Server host is required";
}
if (
!formData.serverPort ||
formData.serverPort < 1 ||
formData.serverPort > 65535
) {
newErrors.serverPort = "Port must be between 1 and 65535";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = () => {
if (validateForm()) {
updateSettingsMutation.mutate(formData);
}
};
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">
{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>
)}
<form className="space-y-6">
<div className="flex items-center mb-6">
<Server className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
Server Configuration
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label
htmlFor={protocolId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Protocol
</label>
<select
id={protocolId}
value={formData.serverProtocol}
onChange={(e) =>
handleInputChange("serverProtocol", e.target.value)
}
className="w-full 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"
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
<div>
<label
htmlFor={hostId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Host *
</label>
<input
id={hostId}
type="text"
value={formData.serverHost}
onChange={(e) => handleInputChange("serverHost", e.target.value)}
className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
errors.serverHost
? "border-red-300 dark:border-red-500"
: "border-secondary-300 dark:border-secondary-600"
}`}
placeholder="example.com"
/>
{errors.serverHost && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
{errors.serverHost}
</p>
)}
</div>
<div>
<label
htmlFor={portId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Port *
</label>
<input
id={portId}
type="number"
value={formData.serverPort}
onChange={(e) =>
handleInputChange("serverPort", parseInt(e.target.value, 10))
}
className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
errors.serverPort
? "border-red-300 dark:border-red-500"
: "border-secondary-300 dark:border-secondary-600"
}`}
min="1"
max="65535"
/>
{errors.serverPort && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
{errors.serverPort}
</p>
)}
</div>
</div>
<div className="mt-4 p-4 bg-secondary-50 dark:bg-secondary-700 rounded-md">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
Server URL
</h4>
<p className="text-sm text-secondary-600 dark:text-secondary-300 font-mono">
{formData.serverProtocol}://{formData.serverHost}:
{formData.serverPort}
</p>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
This URL will be used in installation scripts and agent
communications.
</p>
</div>
{/* Security Notice */}
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
<div className="flex">
<Shield 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">
Security Notice
</h3>
<p className="mt-1 text-sm text-blue-700 dark:text-blue-300">
Changing these settings will affect all installation scripts and
agent communications. Make sure the server URL is accessible
from your client networks.
</p>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<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>
{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>
</div>
</div>
)}
</form>
</div>
);
};
export default ProtocolUrlTab;

View File

@@ -0,0 +1,568 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
BarChart3,
CheckCircle,
Download,
Edit,
Package,
Save,
Server,
Settings,
Shield,
Trash2,
Users,
X,
} from "lucide-react";
import { useEffect, useId, useState } from "react";
import { useAuth } from "../../contexts/AuthContext";
import { permissionsAPI } from "../../utils/api";
const RolesTab = () => {
const [editingRole, setEditingRole] = useState(null);
const [showAddModal, setShowAddModal] = useState(false);
const queryClient = useQueryClient();
const { refreshPermissions } = useAuth();
// Listen for the header button event to open add modal
useEffect(() => {
const handleOpenAddModal = () => setShowAddModal(true);
window.addEventListener("openAddRoleModal", handleOpenAddModal);
return () =>
window.removeEventListener("openAddRoleModal", handleOpenAddModal);
}, []);
// Fetch all role permissions
const {
data: roles,
isLoading,
error,
} = useQuery({
queryKey: ["rolePermissions"],
queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
});
// Update role permissions mutation
const updateRoleMutation = useMutation({
mutationFn: ({ role, permissions }) =>
permissionsAPI.updateRole(role, permissions),
onSuccess: () => {
queryClient.invalidateQueries(["rolePermissions"]);
setEditingRole(null);
// Refresh user permissions to apply changes immediately
refreshPermissions();
},
});
// Delete role mutation
const deleteRoleMutation = useMutation({
mutationFn: (role) => permissionsAPI.deleteRole(role),
onSuccess: () => {
queryClient.invalidateQueries(["rolePermissions"]);
},
});
const handleSavePermissions = async (role, permissions) => {
try {
await updateRoleMutation.mutateAsync({ role, permissions });
} catch (error) {
console.error("Failed to update permissions:", error);
}
};
const handleDeleteRole = async (role) => {
if (
window.confirm(
`Are you sure you want to delete the "${role}" role? This action cannot be undone.`,
)
) {
try {
await deleteRoleMutation.mutateAsync(role);
} catch (error) {
console.error("Failed to delete role:", 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-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 permissions
</h3>
<p className="mt-1 text-sm text-danger-700">{error.message}</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Roles Matrix Table */}
<div className="bg-white dark:bg-secondary-800 shadow overflow-hidden sm:rounded-lg">
<div className="overflow-x-auto">
<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">
Permission
</th>
{roles &&
Array.isArray(roles) &&
roles.map((r) => (
<th
key={r.role}
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"
>
<div className="flex items-center gap-2">
<span className="capitalize">
{r.role.replace(/_/g, " ")}
</span>
<button
type="button"
onClick={() => setEditingRole(r.role)}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-400 dark:hover:text-secondary-200"
title="Edit role permissions"
>
<Edit className="h-4 w-4" />
</button>
</div>
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{roles &&
Array.isArray(roles) &&
roles.length > 0 &&
Object.keys(roles[0])
.filter((k) => k.startsWith("can_"))
.map((permKey) => (
<tr
key={permKey}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
>
<td className="px-6 py-3 text-sm font-medium text-secondary-700 dark:text-secondary-200 whitespace-nowrap">
{permKey
.replace(/^can_/, "")
.split("_")
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join(" ")}
</td>
{roles.map((r) => (
<td
key={`${r.role}-${permKey}`}
className="px-6 py-3 whitespace-nowrap"
>
{r[permKey] ? (
<div className="flex items-center text-green-600">
<CheckCircle className="h-4 w-4" />
</div>
) : (
<div className="flex items-center text-red-600">
<X className="h-4 w-4" />
</div>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Inline editor for selected role */}
{editingRole && roles && Array.isArray(roles) && (
<div className="space-y-4">
{roles
.filter((r) => r.role === editingRole)
.map((r) => (
<RolePermissionsCard
key={`editor-${r.role}`}
role={r}
isEditing={true}
onEdit={() => {}}
onCancel={() => setEditingRole(null)}
onSave={handleSavePermissions}
onDelete={handleDeleteRole}
/>
))}
</div>
)}
{/* Add Role Modal */}
<AddRoleModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
onSuccess={() => {
queryClient.invalidateQueries(["rolePermissions"]);
setShowAddModal(false);
}}
/>
</div>
);
};
// Role Permissions Card Component
const RolePermissionsCard = ({
role,
isEditing,
onEdit,
onCancel,
onSave,
onDelete,
}) => {
const [permissions, setPermissions] = useState(role);
// Sync permissions state with role prop when it changes
useEffect(() => {
setPermissions(role);
}, [role]);
const permissionFields = [
{
key: "can_view_dashboard",
label: "View Dashboard",
icon: BarChart3,
description: "Access to the main dashboard",
},
{
key: "can_view_hosts",
label: "View Hosts",
icon: Server,
description: "See host information and status",
},
{
key: "can_manage_hosts",
label: "Manage Hosts",
icon: Edit,
description: "Add, edit, and delete hosts",
},
{
key: "can_view_packages",
label: "View Packages",
icon: Package,
description: "See package information",
},
{
key: "can_manage_packages",
label: "Manage Packages",
icon: Settings,
description: "Edit package details",
},
{
key: "can_view_users",
label: "View Users",
icon: Users,
description: "See user list and details",
},
{
key: "can_manage_users",
label: "Manage Users",
icon: Shield,
description: "Add, edit, and delete users",
},
{
key: "can_view_reports",
label: "View Reports",
icon: BarChart3,
description: "Access to reports and analytics",
},
{
key: "can_export_data",
label: "Export Data",
icon: Download,
description: "Download data and reports",
},
{
key: "can_manage_settings",
label: "Manage Settings",
icon: Settings,
description: "System configuration access",
},
];
const handlePermissionChange = (key, value) => {
setPermissions((prev) => ({
...prev,
[key]: value,
}));
};
const handleSave = () => {
onSave(role.role, permissions);
};
const isBuiltInRole = role.role === "admin" || role.role === "user";
return (
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center justify-between">
<div className="flex items-center">
<Shield className="h-5 w-5 text-primary-600 mr-3" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white capitalize">
{role.role}
</h3>
{isBuiltInRole && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
Built-in Role
</span>
)}
</div>
<div className="flex items-center space-x-2">
{isEditing ? (
<>
<button
type="button"
onClick={handleSave}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700"
>
<Save className="h-4 w-4 mr-1" />
Save
</button>
<button
type="button"
onClick={onCancel}
className="inline-flex items-center px-3 py-1 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
<X className="h-4 w-4 mr-1" />
Cancel
</button>
{!isBuiltInRole && (
<button
type="button"
onClick={() => onDelete(role.role)}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</button>
)}
</>
) : (
<>
<button
type="button"
onClick={onEdit}
disabled={isBuiltInRole}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Edit className="h-4 w-4 mr-1" />
Edit
</button>
{!isBuiltInRole && (
<button
type="button"
onClick={() => onDelete(role.role)}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</button>
)}
</>
)}
</div>
</div>
</div>
<div className="px-6 py-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{permissionFields.map((field) => {
const Icon = field.icon;
const isChecked = permissions[field.key];
return (
<div key={field.key} className="flex items-start">
<div className="flex items-center h-5">
<input
id={`${role.role}-${field.key}`}
type="checkbox"
checked={isChecked}
onChange={(e) =>
handlePermissionChange(field.key, e.target.checked)
}
disabled={
!isEditing ||
(isBuiltInRole && field.key === "can_manage_users")
}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded disabled:opacity-50"
/>
</div>
<div className="ml-3">
<div className="flex items-center">
<Icon className="h-4 w-4 text-secondary-400 mr-2" />
<label
htmlFor={`${role.role}-${field.key}`}
className="text-sm font-medium text-secondary-900 dark:text-white"
>
{field.label}
</label>
</div>
<p className="text-xs text-secondary-500 mt-1">
{field.description}
</p>
</div>
</div>
);
})}
</div>
</div>
</div>
);
};
// Add Role Modal Component
const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
const roleNameInputId = useId();
const [formData, setFormData] = useState({
role: "",
can_view_dashboard: true,
can_view_hosts: true,
can_manage_hosts: false,
can_view_packages: true,
can_manage_packages: false,
can_view_users: false,
can_manage_users: false,
can_view_reports: true,
can_export_data: false,
can_manage_settings: false,
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError("");
try {
await permissionsAPI.updateRole(formData.role, formData);
onSuccess();
} catch (err) {
setError(err.response?.data?.error || "Failed to create role");
} finally {
setIsLoading(false);
}
};
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData({
...formData,
[name]: type === "checkbox" ? checked : value,
});
};
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 p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Add New Role
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={roleNameInputId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Role Name
</label>
<input
id={roleNameInputId}
type="text"
name="role"
required
value={formData.role}
onChange={handleInputChange}
className="block w-full 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"
placeholder="e.g., host_manager, readonly"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Use lowercase with underscores (e.g., host_manager)
</p>
</div>
<div className="space-y-3">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white">
Permissions
</h4>
{[
{ key: "can_view_dashboard", label: "View Dashboard" },
{ key: "can_view_hosts", label: "View Hosts" },
{ key: "can_manage_hosts", label: "Manage Hosts" },
{ key: "can_view_packages", label: "View Packages" },
{ key: "can_manage_packages", label: "Manage Packages" },
{ key: "can_view_users", label: "View Users" },
{ key: "can_manage_users", label: "Manage Users" },
{ key: "can_view_reports", label: "View Reports" },
{ key: "can_export_data", label: "Export Data" },
{ key: "can_manage_settings", label: "Manage Settings" },
].map((permission) => (
<div key={permission.key} className="flex items-center">
<input
id={`add-role-${permission.key}`}
type="checkbox"
name={permission.key}
checked={formData[permission.key]}
onChange={handleInputChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<label
htmlFor={`add-role-${permission.key}`}
className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200"
>
{permission.label}
</label>
</div>
))}
</div>
{error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">
{error}
</p>
</div>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{isLoading ? "Creating..." : "Create Role"}
</button>
</div>
</form>
</div>
</div>
);
};
export default RolesTab;

View File

@@ -0,0 +1,896 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Calendar,
CheckCircle,
Edit,
Key,
Mail,
Shield,
Trash2,
User,
XCircle,
} from "lucide-react";
import { useEffect, useId, useState } from "react";
import { useAuth } from "../../contexts/AuthContext";
import { adminUsersAPI, permissionsAPI } from "../../utils/api";
const UsersTab = () => {
const [showAddModal, setShowAddModal] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [resetPasswordUser, setResetPasswordUser] = useState(null);
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
// Listen for the header button event to open add modal
useEffect(() => {
const handleOpenAddModal = () => setShowAddModal(true);
window.addEventListener("openAddUserModal", handleOpenAddModal);
return () =>
window.removeEventListener("openAddUserModal", handleOpenAddModal);
}, []);
// Fetch users
const {
data: users,
isLoading,
error,
} = useQuery({
queryKey: ["users"],
queryFn: () => adminUsersAPI.list().then((res) => res.data),
});
// Fetch available roles
const { data: roles } = useQuery({
queryKey: ["rolePermissions"],
queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
});
// Delete user mutation
const deleteUserMutation = useMutation({
mutationFn: adminUsersAPI.delete,
onSuccess: () => {
queryClient.invalidateQueries(["users"]);
},
});
// Update user mutation
const updateUserMutation = useMutation({
mutationFn: ({ id, data }) => adminUsersAPI.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries(["users"]);
setEditingUser(null);
},
});
// Reset password mutation
const resetPasswordMutation = useMutation({
mutationFn: ({ userId, newPassword }) =>
adminUsersAPI.resetPassword(userId, newPassword),
onSuccess: () => {
queryClient.invalidateQueries(["users"]);
setResetPasswordUser(null);
},
});
const handleDeleteUser = async (userId, username) => {
if (
window.confirm(
`Are you sure you want to delete user "${username}"? This action cannot be undone.`,
)
) {
try {
await deleteUserMutation.mutateAsync(userId);
} catch (error) {
console.error("Failed to delete user:", error);
}
}
};
const handleUserCreated = () => {
queryClient.invalidateQueries(["users"]);
setShowAddModal(false);
};
const handleEditUser = (user) => {
setEditingUser(user);
};
const handleResetPassword = (user) => {
setResetPasswordUser(user);
};
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-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<XCircle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">
Error loading users
</h3>
<p className="mt-1 text-sm text-danger-700">{error.message}</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Users Table */}
<div className="bg-white dark:bg-secondary-800 shadow overflow-hidden sm:rounded-lg">
<div className="overflow-x-auto">
<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">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Role
</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">
Created
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Last Login
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{users && Array.isArray(users) && users.length > 0 ? (
users.map((user) => (
<tr
key={user.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="h-10 w-10 rounded-full bg-primary-100 flex items-center justify-center">
<User className="h-5 w-5 text-primary-600" />
</div>
</div>
<div className="ml-4">
<div className="flex items-center">
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{user.username}
</div>
{user.id === currentUser?.id && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
You
</span>
)}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm text-secondary-500 dark:text-secondary-300">
<Mail className="h-4 w-4 mr-2" />
{user.email}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.role === "admin"
? "bg-primary-100 text-primary-800"
: user.role === "host_manager"
? "bg-green-100 text-green-800"
: user.role === "readonly"
? "bg-yellow-100 text-yellow-800"
: "bg-secondary-100 text-secondary-800"
}`}
>
<Shield className="h-3 w-3 mr-1" />
{user.role.charAt(0).toUpperCase() +
user.role.slice(1).replace("_", " ")}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{user.is_active ? (
<div className="flex items-center text-green-600">
<CheckCircle className="h-4 w-4 mr-1" />
<span className="text-sm">Active</span>
</div>
) : (
<div className="flex items-center text-red-600">
<XCircle className="h-4 w-4 mr-1" />
<span className="text-sm">Inactive</span>
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm text-secondary-500 dark:text-secondary-300">
<Calendar className="h-4 w-4 mr-2" />
{new Date(user.created_at).toLocaleDateString()}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-300">
{user.last_login ? (
new Date(user.last_login).toLocaleDateString()
) : (
<span className="text-secondary-400">Never</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end space-x-2">
<button
type="button"
onClick={() => handleEditUser(user)}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
title="Edit user"
>
<Edit className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => handleResetPassword(user)}
className="text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300 disabled:text-gray-300 disabled:cursor-not-allowed"
title={
!user.is_active
? "Cannot reset password for inactive user"
: "Reset password"
}
disabled={!user.is_active}
>
<Key className="h-4 w-4" />
</button>
<button
type="button"
onClick={() =>
handleDeleteUser(user.id, user.username)
}
className="text-danger-400 hover:text-danger-600 dark:text-danger-500 dark:hover:text-danger-400 disabled:text-gray-300 disabled:cursor-not-allowed"
title={
user.id === currentUser?.id
? "Cannot delete your own account"
: user.role === "admin" &&
users.filter((u) => u.role === "admin")
.length === 1
? "Cannot delete the last admin user"
: "Delete user"
}
disabled={
user.id === currentUser?.id ||
(user.role === "admin" &&
users.filter((u) => u.role === "admin").length ===
1)
}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan="7" className="px-6 py-12 text-center">
<User className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
No users found
</p>
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
Click "Add User" to create the first user
</p>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Add User Modal */}
<AddUserModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
onUserCreated={handleUserCreated}
roles={roles}
/>
{/* Edit User Modal */}
{editingUser && (
<EditUserModal
user={editingUser}
isOpen={!!editingUser}
onClose={() => setEditingUser(null)}
onUserUpdated={() => updateUserMutation.mutate()}
roles={roles}
/>
)}
{/* Reset Password Modal */}
{resetPasswordUser && (
<ResetPasswordModal
user={resetPasswordUser}
isOpen={!!resetPasswordUser}
onClose={() => setResetPasswordUser(null)}
onPasswordReset={resetPasswordMutation.mutate}
isLoading={resetPasswordMutation.isPending}
/>
)}
</div>
);
};
// Add User Modal Component
const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
const usernameId = useId();
const emailId = useId();
const firstNameId = useId();
const lastNameId = useId();
const passwordId = useId();
const roleId = useId();
const [formData, setFormData] = useState({
username: "",
email: "",
password: "",
first_name: "",
last_name: "",
role: "user",
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError("");
try {
// Only send role if roles are available from API
const payload = {
username: formData.username,
email: formData.email,
password: formData.password,
};
if (roles && Array.isArray(roles) && roles.length > 0) {
payload.role = formData.role;
}
await adminUsersAPI.create(payload);
onUserCreated();
} catch (err) {
setError(err.response?.data?.error || "Failed to create user");
} finally {
setIsLoading(false);
}
};
const handleInputChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
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 p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Add New User
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={usernameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Username
</label>
<input
id={usernameId}
type="text"
name="username"
required
value={formData.username}
onChange={handleInputChange}
className="block w-full 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"
/>
</div>
<div>
<label
htmlFor={emailId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Email
</label>
<input
id={emailId}
type="email"
name="email"
required
value={formData.email}
onChange={handleInputChange}
className="block w-full 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"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label
htmlFor={firstNameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
First Name
</label>
<input
id={firstNameId}
type="text"
name="first_name"
value={formData.first_name}
onChange={handleInputChange}
className="block w-full 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"
/>
</div>
<div>
<label
htmlFor={lastNameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Last Name
</label>
<input
id={lastNameId}
type="text"
name="last_name"
value={formData.last_name}
onChange={handleInputChange}
className="block w-full 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"
/>
</div>
</div>
<div>
<label
htmlFor={passwordId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Password
</label>
<input
id={passwordId}
type="password"
name="password"
required
minLength={6}
value={formData.password}
onChange={handleInputChange}
className="block w-full 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"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Minimum 6 characters
</p>
</div>
<div>
<label
htmlFor={roleId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Role
</label>
<select
id={roleId}
name="role"
value={formData.role}
onChange={handleInputChange}
className="block w-full 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"
>
{roles && Array.isArray(roles) && roles.length > 0 ? (
roles.map((role) => (
<option key={role.role} value={role.role}>
{role.role.charAt(0).toUpperCase() +
role.role.slice(1).replace("_", " ")}
</option>
))
) : (
<>
<option value="user">User</option>
<option value="admin">Admin</option>
</>
)}
</select>
</div>
{error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">
{error}
</p>
</div>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{isLoading ? "Creating..." : "Create User"}
</button>
</div>
</form>
</div>
</div>
);
};
// Edit User Modal Component
const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
const editUsernameId = useId();
const editEmailId = useId();
const editFirstNameId = useId();
const editLastNameId = useId();
const editRoleId = useId();
const editActiveId = useId();
const [formData, setFormData] = useState({
username: user?.username || "",
email: user?.email || "",
first_name: user?.first_name || "",
last_name: user?.last_name || "",
role: user?.role || "user",
is_active: user?.is_active ?? true,
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError("");
try {
await adminUsersAPI.update(user.id, formData);
onUserUpdated();
} catch (err) {
setError(err.response?.data?.error || "Failed to update user");
} finally {
setIsLoading(false);
}
};
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData({
...formData,
[name]: type === "checkbox" ? checked : value,
});
};
if (!isOpen || !user) 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 p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Edit User
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={editUsernameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Username
</label>
<input
id={editUsernameId}
type="text"
name="username"
required
value={formData.username}
onChange={handleInputChange}
className="block w-full 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"
/>
</div>
<div>
<label
htmlFor={editEmailId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Email
</label>
<input
id={editEmailId}
type="email"
name="email"
required
value={formData.email}
onChange={handleInputChange}
className="block w-full 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"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label
htmlFor={editFirstNameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
First Name
</label>
<input
id={editFirstNameId}
type="text"
name="first_name"
value={formData.first_name}
onChange={handleInputChange}
className="block w-full 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"
/>
</div>
<div>
<label
htmlFor={editLastNameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Last Name
</label>
<input
id={editLastNameId}
type="text"
name="last_name"
value={formData.last_name}
onChange={handleInputChange}
className="block w-full 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"
/>
</div>
</div>
<div>
<label
htmlFor={editRoleId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Role
</label>
<select
id={editRoleId}
name="role"
value={formData.role}
onChange={handleInputChange}
className="block w-full 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"
>
{roles && Array.isArray(roles) ? (
roles.map((role) => (
<option key={role.role} value={role.role}>
{role.role.charAt(0).toUpperCase() +
role.role.slice(1).replace("_", " ")}
</option>
))
) : (
<>
<option value="user">User</option>
<option value="admin">Admin</option>
</>
)}
</select>
</div>
<div className="flex items-center">
<input
id={editActiveId}
type="checkbox"
name="is_active"
checked={formData.is_active}
onChange={handleInputChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<label
htmlFor={editActiveId}
className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200"
>
Active user
</label>
</div>
{error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">
{error}
</p>
</div>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{isLoading ? "Updating..." : "Update User"}
</button>
</div>
</form>
</div>
</div>
);
};
// Reset Password Modal Component
const ResetPasswordModal = ({
user,
isOpen,
onClose,
onPasswordReset,
isLoading,
}) => {
const newPasswordId = useId();
const confirmPasswordId = useId();
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
// Validate passwords
if (newPassword.length < 6) {
setError("Password must be at least 6 characters long");
return;
}
if (newPassword !== confirmPassword) {
setError("Passwords do not match");
return;
}
try {
await onPasswordReset({ userId: user.id, newPassword });
// Reset form on success
setNewPassword("");
setConfirmPassword("");
} catch (err) {
setError(err.response?.data?.error || "Failed to reset password");
}
};
const handleClose = () => {
setNewPassword("");
setConfirmPassword("");
setError("");
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Reset Password for {user.username}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={newPasswordId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
New Password
</label>
<input
id={newPasswordId}
type="password"
required
minLength={6}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="block w-full 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"
placeholder="Enter new password (min 6 characters)"
/>
</div>
<div>
<label
htmlFor={confirmPasswordId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Confirm Password
</label>
<input
id={confirmPasswordId}
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="block w-full 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"
placeholder="Confirm new password"
/>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 rounded-md p-3">
<div className="flex">
<div className="flex-shrink-0">
<Key className="h-5 w-5 text-yellow-400" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Password Reset Warning
</h3>
<div className="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
<p>
This will immediately change the user's password. The user
will need to use the new password to login.
</p>
</div>
</div>
</div>
</div>
{error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">
{error}
</p>
</div>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={handleClose}
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50 flex items-center"
>
{isLoading && (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
)}
{isLoading ? "Resetting..." : "Reset Password"}
</button>
</div>
</form>
</div>
</div>
);
};
export default UsersTab;

View File

@@ -0,0 +1,573 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle,
Clock,
Code,
Download,
Save,
} from "lucide-react";
import { useEffect, useId, useState } from "react";
import { settingsAPI, 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,
latestVersion: null,
isUpdateAvailable: false,
checking: false,
error: 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 () => {
setVersionInfo((prev) => ({ ...prev, checking: true, error: null }));
try {
const response = await versionAPI.checkUpdates();
const data = response.data;
setVersionInfo({
currentVersion: data.currentVersion,
latestVersion: data.latestVersion,
isUpdateAvailable: data.isUpdateAvailable,
last_update_check: data.last_update_check,
checking: false,
error: null,
});
} catch (error) {
console.error("Version check error:", error);
setVersionInfo((prev) => ({
...prev,
checking: false,
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;
}
setSshTestResult({
testing: true,
success: null,
message: null,
error: null,
});
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>
);
}
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
</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
</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.
</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}
</span>
</div>
<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
</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
</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>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default VersionUpdateTab;

View File

@@ -102,6 +102,15 @@ const HostDetail = () => {
},
});
const updateNotesMutation = useMutation({
mutationFn: ({ hostId, notes }) =>
adminHostsAPI.updateNotes(hostId, notes).then((res) => res.data),
onSuccess: () => {
queryClient.invalidateQueries(["host", hostId]);
queryClient.invalidateQueries(["hosts"]);
},
});
const handleDeleteHost = async () => {
if (
window.confirm(
@@ -315,17 +324,6 @@ const HostDetail = () => {
>
System
</button>
<button
type="button"
onClick={() => handleTabChange("monitoring")}
className={`px-4 py-2 text-sm font-medium ${
activeTab === "monitoring"
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500"
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300"
}`}
>
Resource
</button>
<button
type="button"
onClick={() => handleTabChange("history")}
@@ -335,7 +333,18 @@ const HostDetail = () => {
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300"
}`}
>
Update History
Agent History
</button>
<button
type="button"
onClick={() => handleTabChange("notes")}
className={`px-4 py-2 text-sm font-medium ${
activeTab === "notes"
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500"
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300"
}`}
>
Notes
</button>
</div>
@@ -419,7 +428,7 @@ const HostDetail = () => {
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
Auto-update
Agent Auto-update
</p>
<button
type="button"
@@ -506,55 +515,279 @@ const HostDetail = () => {
)}
{/* System Information */}
{activeTab === "system" &&
(host.kernel_version ||
host.selinux_status ||
host.architecture) && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{host.architecture && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
Architecture
</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.architecture}
</p>
</div>
)}
{activeTab === "system" && (
<div className="space-y-6">
{/* Basic System Information */}
{(host.kernel_version ||
host.selinux_status ||
host.architecture) && (
<div>
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3 flex items-center gap-2">
<Terminal className="h-4 w-4 text-primary-600 dark:text-primary-400" />
System Information
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{host.architecture && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
Architecture
</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.architecture}
</p>
</div>
)}
{host.kernel_version && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
Kernel Version
</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">
{host.kernel_version}
</p>
</div>
)}
{host.kernel_version && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
Kernel Version
</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">
{host.kernel_version}
</p>
</div>
)}
{host.selinux_status && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
SELinux Status
</p>
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
host.selinux_status === "enabled"
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
: host.selinux_status === "permissive"
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
}`}
>
{host.selinux_status}
</span>
</div>
)}
{/* Empty div to push SELinux status to the right */}
<div></div>
{host.selinux_status && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
SELinux Status
</p>
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
host.selinux_status === "enabled"
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
: host.selinux_status === "permissive"
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
}`}
>
{host.selinux_status}
</span>
</div>
)}
</div>
</div>
</div>
)}
)}
{/* Resource Information */}
{(host.system_uptime ||
host.cpu_model ||
host.cpu_cores ||
host.ram_installed ||
host.swap_size !== undefined ||
(host.load_average &&
Array.isArray(host.load_average) &&
host.load_average.length > 0 &&
host.load_average.some((load) => load != null)) ||
(host.disk_details &&
Array.isArray(host.disk_details) &&
host.disk_details.length > 0)) && (
<div className="pt-4 border-t border-secondary-200 dark:border-secondary-600">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3 flex items-center gap-2">
<Monitor className="h-4 w-4 text-primary-600 dark:text-primary-400" />
Resource Information
</h4>
{/* System Overview */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{/* System Uptime */}
{host.system_uptime && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Clock className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">
System Uptime
</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.system_uptime}
</p>
</div>
)}
{/* CPU Model */}
{host.cpu_model && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Cpu className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">
CPU Model
</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.cpu_model}
</p>
</div>
)}
{/* CPU Cores */}
{host.cpu_cores && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Cpu className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">
CPU Cores
</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.cpu_cores}
</p>
</div>
)}
{/* RAM Installed */}
{host.ram_installed && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<MemoryStick className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">
RAM Installed
</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.ram_installed} GB
</p>
</div>
)}
{/* Swap Size */}
{host.swap_size !== undefined &&
host.swap_size !== null && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<MemoryStick className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">
Swap Size
</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.swap_size} GB
</p>
</div>
)}
{/* Load Average */}
{host.load_average &&
Array.isArray(host.load_average) &&
host.load_average.length > 0 &&
host.load_average.some((load) => load != null) && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Activity className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">
Load Average
</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.load_average
.filter((load) => load != null)
.map((load, index) => (
<span key={`load-${index}-${load}`}>
{typeof load === "number"
? load.toFixed(2)
: String(load)}
{index <
host.load_average.filter(
(load) => load != null,
).length -
1 && ", "}
</span>
))}
</p>
</div>
)}
</div>
{/* Disk Information */}
{host.disk_details &&
Array.isArray(host.disk_details) &&
host.disk_details.length > 0 && (
<div className="pt-4 border-t border-secondary-200 dark:border-secondary-600">
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-3 flex items-center gap-2">
<HardDrive className="h-4 w-4 text-primary-600 dark:text-primary-400" />
Disk Usage
</h5>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{host.disk_details.map((disk, index) => (
<div
key={disk.name || `disk-${index}`}
className="bg-secondary-50 dark:bg-secondary-700 p-3 rounded-lg"
>
<div className="flex items-center gap-2 mb-2">
<HardDrive className="h-4 w-4 text-secondary-500" />
<span className="font-medium text-secondary-900 dark:text-white text-sm">
{disk.name || `Disk ${index + 1}`}
</span>
</div>
{disk.size && (
<p className="text-xs text-secondary-600 dark:text-secondary-300 mb-1">
Size: {disk.size}
</p>
)}
{disk.mountpoint && (
<p className="text-xs text-secondary-600 dark:text-secondary-300 mb-1">
Mount: {disk.mountpoint}
</p>
)}
{disk.usage &&
typeof disk.usage === "number" && (
<div className="mt-2">
<div className="flex justify-between text-xs text-secondary-600 dark:text-secondary-300 mb-1">
<span>Usage</span>
<span>{disk.usage}%</span>
</div>
<div className="w-full bg-secondary-200 dark:bg-secondary-600 rounded-full h-2">
<div
className="bg-primary-600 dark:bg-primary-400 h-2 rounded-full transition-all duration-300"
style={{
width: `${Math.min(Math.max(disk.usage, 0), 100)}%`,
}}
></div>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* No Data State */}
{!host.kernel_version &&
!host.selinux_status &&
!host.architecture &&
!host.system_uptime &&
!host.cpu_model &&
!host.cpu_cores &&
!host.ram_installed &&
host.swap_size === undefined &&
(!host.load_average ||
!Array.isArray(host.load_average) ||
host.load_average.length === 0 ||
!host.load_average.some((load) => load != null)) &&
(!host.disk_details ||
!Array.isArray(host.disk_details) ||
host.disk_details.length === 0) && (
<div className="text-center py-8">
<Terminal className="h-8 w-8 text-secondary-400 mx-auto mb-2" />
<p className="text-sm text-secondary-500 dark:text-secondary-300">
No system information available
</p>
<p className="text-xs text-secondary-400 dark:text-secondary-400 mt-1">
System information will appear once the agent collects
data from this host
</p>
</div>
)}
</div>
)}
{activeTab === "network" &&
!(
@@ -570,213 +803,6 @@ const HostDetail = () => {
</div>
)}
{activeTab === "system" &&
!(
host.kernel_version ||
host.selinux_status ||
host.architecture
) && (
<div className="text-center py-8">
<Terminal className="h-8 w-8 text-secondary-400 mx-auto mb-2" />
<p className="text-sm text-secondary-500 dark:text-secondary-300">
No system information available
</p>
</div>
)}
{/* System Monitoring */}
{activeTab === "monitoring" && (
<div className="space-y-6">
{/* System Overview */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* System Uptime */}
{host.system_uptime && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Clock className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">
System Uptime
</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.system_uptime}
</p>
</div>
)}
{/* CPU Model */}
{host.cpu_model && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Cpu className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">
CPU Model
</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.cpu_model}
</p>
</div>
)}
{/* CPU Cores */}
{host.cpu_cores && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Cpu className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">
CPU Cores
</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.cpu_cores}
</p>
</div>
)}
{/* RAM Installed */}
{host.ram_installed && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<MemoryStick className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">
RAM Installed
</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.ram_installed} GB
</p>
</div>
)}
{/* Swap Size */}
{host.swap_size !== undefined &&
host.swap_size !== null && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<MemoryStick className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">
Swap Size
</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.swap_size} GB
</p>
</div>
)}
{/* Load Average */}
{host.load_average &&
Array.isArray(host.load_average) &&
host.load_average.length > 0 &&
host.load_average.some((load) => load != null) && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Activity className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">
Load Average
</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.load_average
.filter((load) => load != null)
.map((load, index) => (
<span key={`load-${index}-${load}`}>
{typeof load === "number"
? load.toFixed(2)
: String(load)}
{index <
host.load_average.filter(
(load) => load != null,
).length -
1 && ", "}
</span>
))}
</p>
</div>
)}
</div>
{/* Disk Information */}
{host.disk_details &&
Array.isArray(host.disk_details) &&
host.disk_details.length > 0 && (
<div className="pt-4 border-t border-secondary-200 dark:border-secondary-600">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3 flex items-center gap-2">
<HardDrive className="h-4 w-4 text-primary-600 dark:text-primary-400" />
Disk Usage
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{host.disk_details.map((disk, index) => (
<div
key={disk.name || `disk-${index}`}
className="bg-secondary-50 dark:bg-secondary-700 p-3 rounded-lg"
>
<div className="flex items-center gap-2 mb-2">
<HardDrive className="h-4 w-4 text-secondary-500" />
<span className="font-medium text-secondary-900 dark:text-white text-sm">
{disk.name || `Disk ${index + 1}`}
</span>
</div>
{disk.size && (
<p className="text-xs text-secondary-600 dark:text-secondary-300 mb-1">
Size: {disk.size}
</p>
)}
{disk.mountpoint && (
<p className="text-xs text-secondary-600 dark:text-secondary-300 mb-1">
Mount: {disk.mountpoint}
</p>
)}
{disk.usage && typeof disk.usage === "number" && (
<div className="mt-2">
<div className="flex justify-between text-xs text-secondary-600 dark:text-secondary-300 mb-1">
<span>Usage</span>
<span>{disk.usage}%</span>
</div>
<div className="w-full bg-secondary-200 dark:bg-secondary-600 rounded-full h-2">
<div
className="bg-primary-600 dark:bg-primary-400 h-2 rounded-full transition-all duration-300"
style={{
width: `${Math.min(Math.max(disk.usage, 0), 100)}%`,
}}
></div>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* No Data State */}
{!host.system_uptime &&
!host.cpu_model &&
!host.cpu_cores &&
!host.ram_installed &&
host.swap_size === undefined &&
(!host.load_average ||
!Array.isArray(host.load_average) ||
host.load_average.length === 0 ||
!host.load_average.some((load) => load != null)) &&
(!host.disk_details ||
!Array.isArray(host.disk_details) ||
host.disk_details.length === 0) && (
<div className="text-center py-8">
<Monitor className="h-8 w-8 text-secondary-400 mx-auto mb-2" />
<p className="text-sm text-secondary-500 dark:text-secondary-300">
No monitoring data available
</p>
<p className="text-xs text-secondary-400 dark:text-secondary-400 mt-1">
Monitoring data will appear once the agent collects
system information
</p>
</div>
)}
</div>
)}
{/* Update History */}
{activeTab === "history" && (
<div className="overflow-x-auto">
@@ -883,6 +909,56 @@ const HostDetail = () => {
)}
</div>
)}
{/* Notes */}
{activeTab === "notes" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Host Notes
</h3>
</div>
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-4">
<textarea
value={host.notes || ""}
onChange={(e) => {
// Update local state immediately for better UX
const updatedHost = { ...host, notes: e.target.value };
queryClient.setQueryData(["host", hostId], updatedHost);
}}
placeholder="Add notes about this host... (e.g., purpose, special configurations, maintenance notes)"
className="w-full h-32 p-3 border border-secondary-200 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-none"
maxLength={1000}
/>
<div className="flex justify-between items-center mt-3">
<p className="text-xs text-secondary-500 dark:text-secondary-400">
Use this space to add important information about this
host for your team
</p>
<div className="flex items-center gap-2">
<span className="text-xs text-secondary-400 dark:text-secondary-500">
{(host.notes || "").length}/1000
</span>
<button
type="button"
onClick={() => {
updateNotesMutation.mutate({
hostId: host.id,
notes: host.notes || "",
});
}}
disabled={updateNotesMutation.isPending}
className="px-3 py-1.5 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 disabled:bg-primary-400 rounded-md transition-colors"
>
{updateNotesMutation.isPending
? "Saving..."
: "Save Notes"}
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
@@ -993,6 +1069,17 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
const serverUrl = serverUrlData?.server_url || "http://localhost:3001";
// Fetch settings for dynamic curl flags (local to modal)
const { data: settings } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Helper function to get curl flags based on settings
const getCurlFlags = () => {
return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
};
const copyToClipboard = async (text) => {
try {
// Try modern clipboard API first
@@ -1089,7 +1176,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
<div className="flex items-center gap-2">
<input
type="text"
value={`curl -ks ${serverUrl}/api/v1/hosts/install -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | bash`}
value={`curl ${getCurlFlags()} ${serverUrl}/api/v1/hosts/install -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"
/>
@@ -1097,7 +1184,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
type="button"
onClick={() =>
copyToClipboard(
`curl -ks ${serverUrl}/api/v1/hosts/install -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | bash`,
`curl ${getCurlFlags()} ${serverUrl}/api/v1/hosts/install -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | bash`,
)
}
className="btn-primary flex items-center gap-1"
@@ -1147,7 +1234,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
<div className="flex items-center gap-2">
<input
type="text"
value={`curl -ko /usr/local/bin/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" && sudo chmod +x /usr/local/bin/patchmon-agent.sh`}
value={`curl ${getCurlFlags()} -o /usr/local/bin/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" && sudo chmod +x /usr/local/bin/patchmon-agent.sh`}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
@@ -1155,7 +1242,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
type="button"
onClick={() =>
copyToClipboard(
`curl -ko /usr/local/bin/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" && sudo chmod +x /usr/local/bin/patchmon-agent.sh`,
`curl ${getCurlFlags()} -o /usr/local/bin/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" && sudo chmod +x /usr/local/bin/patchmon-agent.sh`,
)
}
className="btn-secondary flex items-center gap-1"

View File

@@ -257,6 +257,7 @@ const Hosts = () => {
const filter = searchParams.get("filter");
const showFiltersParam = searchParams.get("showFilters");
const osFilterParam = searchParams.get("osFilter");
const groupParam = searchParams.get("group");
if (filter === "needsUpdates") {
setShowFilters(true);
@@ -284,6 +285,12 @@ const Hosts = () => {
setOsFilter(osFilterParam);
}
// Handle group filter parameter
if (groupParam) {
setShowFilters(true);
setGroupFilter(groupParam);
}
// Handle add host action from navigation
const action = searchParams.get("action");
if (action === "add") {
@@ -334,8 +341,9 @@ const Hosts = () => {
},
{ id: "status", label: "Status", visible: true, order: 8 },
{ id: "updates", label: "Updates", visible: true, order: 9 },
{ id: "last_update", label: "Last Update", visible: true, order: 10 },
{ id: "actions", label: "Actions", visible: true, order: 11 },
{ id: "notes", label: "Notes", visible: false, order: 10 },
{ id: "last_update", label: "Last Update", visible: true, order: 11 },
{ id: "actions", label: "Actions", visible: true, order: 12 },
];
const saved = localStorage.getItem("hosts-column-config");
@@ -535,7 +543,8 @@ const Hosts = () => {
searchTerm === "" ||
host.friendly_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.ip?.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.os_type?.toLowerCase().includes(searchTerm.toLowerCase());
host.os_type?.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.notes?.toLowerCase().includes(searchTerm.toLowerCase());
// Group filter
const matchesGroup =
@@ -621,6 +630,10 @@ const Hosts = () => {
aValue = new Date(a.last_update);
bValue = new Date(b.last_update);
break;
case "notes":
aValue = (a.notes || "").toLowerCase();
bValue = (b.notes || "").toLowerCase();
break;
default:
aValue = a[sortField];
bValue = b[sortField];
@@ -870,6 +883,20 @@ const Hosts = () => {
{formatRelativeTime(host.last_update)}
</div>
);
case "notes":
return (
<div className="text-sm text-secondary-900 dark:text-white max-w-xs">
{host.notes ? (
<div className="truncate" title={host.notes}>
{host.notes}
</div>
) : (
<span className="text-secondary-400 dark:text-secondary-500 italic">
No notes
</span>
)}
</div>
);
case "actions":
return (
<Link

View File

@@ -22,6 +22,7 @@ const Login = () => {
const emailId = useId();
const passwordId = useId();
const tokenId = useId();
const { login, setAuthState } = useAuth();
const [isSignupMode, setIsSignupMode] = useState(false);
const [formData, setFormData] = useState({
username: "",
@@ -41,7 +42,6 @@ const Login = () => {
const [signupEnabled, setSignupEnabled] = useState(false);
const navigate = useNavigate();
const { login, setAuthState } = useAuth();
// Check if signup is enabled
useEffect(() => {
@@ -130,9 +130,8 @@ const Login = () => {
const response = await authAPI.verifyTfa(tfaUsername, tfaData.token);
if (response.data?.token) {
// Store token and user data
localStorage.setItem("token", response.data.token);
localStorage.setItem("user", JSON.stringify(response.data.user));
// Update AuthContext with the new authentication state
setAuthState(response.data.token, response.data.user);
// Redirect to dashboard
navigate("/");

View File

@@ -11,7 +11,6 @@ import {
Moon,
RefreshCw,
Save,
Settings,
Shield,
Smartphone,
Sun,
@@ -142,7 +141,6 @@ const Profile = () => {
{ id: "profile", name: "Profile Information", icon: User },
{ id: "password", name: "Change Password", icon: Key },
{ id: "tfa", name: "Multi-Factor Authentication", icon: Smartphone },
{ id: "preferences", name: "Preferences", icon: Settings },
];
return (
@@ -338,6 +336,52 @@ const Profile = () => {
</div>
</div>
{/* Theme Settings */}
<div className="border-t border-secondary-200 dark:border-secondary-600 pt-6">
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-3">
Appearance
</h4>
<div className="max-w-md">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
{isDark ? (
<Moon className="h-5 w-5 text-secondary-600 dark:text-secondary-400" />
) : (
<Sun className="h-5 w-5 text-secondary-600 dark:text-secondary-400" />
)}
</div>
<div>
<p className="text-sm font-medium text-secondary-900 dark:text-white">
{isDark ? "Dark Mode" : "Light Mode"}
</p>
<p className="text-xs text-secondary-500 dark:text-secondary-400">
{isDark
? "Switch to light mode"
: "Switch to dark mode"}
</p>
</div>
</div>
<button
type="button"
onClick={toggleTheme}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
isDark ? "bg-primary-600" : "bg-secondary-200"
}`}
role="switch"
aria-checked={isDark}
>
<span
aria-hidden="true"
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
isDark ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
@@ -477,62 +521,6 @@ const Profile = () => {
{/* Multi-Factor Authentication Tab */}
{activeTab === "tfa" && <TfaTab />}
{/* Preferences Tab */}
{activeTab === "preferences" && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Preferences
</h3>
{/* Theme Settings */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-3">
Appearance
</h4>
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
{isDark ? (
<Moon className="h-5 w-5 text-secondary-600 dark:text-secondary-400" />
) : (
<Sun className="h-5 w-5 text-secondary-600 dark:text-secondary-400" />
)}
</div>
<div>
<p className="text-sm font-medium text-secondary-900 dark:text-white">
{isDark ? "Dark Mode" : "Light Mode"}
</p>
<p className="text-xs text-secondary-500 dark:text-secondary-400">
{isDark
? "Switch to light mode"
: "Switch to dark mode"}
</p>
</div>
</div>
<button
type="button"
onClick={toggleTheme}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
isDark ? "bg-primary-600" : "bg-secondary-300"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
isDark ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
@@ -541,6 +529,7 @@ const Profile = () => {
// TFA Tab Component
const TfaTab = () => {
const verificationTokenId = useId();
const [setupStep, setSetupStep] = useState("status"); // 'status', 'setup', 'verify', 'backup-codes'
const [verificationToken, setVerificationToken] = useState("");
const [password, setPassword] = useState("");

View File

@@ -108,6 +108,11 @@ const Settings = () => {
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Helper function to get curl flags based on settings
const getCurlFlags = () => {
return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
};
// Fetch available roles for default user role dropdown
const { data: roles, isLoading: rolesLoading } = useQuery({
queryKey: ["rolePermissions"],
@@ -938,13 +943,13 @@ const Settings = () => {
</p>
<div className="flex items-center gap-2">
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
curl -ks {window.location.origin}
curl {getCurlFlags()} {window.location.origin}
/api/v1/hosts/remove | sudo bash
</div>
<button
type="button"
onClick={() => {
const command = `curl -ks ${window.location.origin}/api/v1/hosts/remove | sudo bash`;
const command = `curl ${getCurlFlags()} ${window.location.origin}/api/v1/hosts/remove | sudo bash`;
navigator.clipboard.writeText(command);
// You could add a toast notification here
}}

View File

@@ -0,0 +1,67 @@
import { Bell } from "lucide-react";
const AlertChannels = () => {
return (
<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">
Alert Channels
</h1>
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
Configure how PatchMon sends notifications and alerts
</p>
</div>
</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-primary-100 dark:bg-primary-900 rounded-lg flex items-center justify-center">
<Bell className="h-6 w-6 text-primary-600 dark:text-primary-400" />
</div>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Alert Channels Coming Soon
</h3>
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
We're working on adding support for multiple alert channels
including email, Slack, Discord, and webhooks.
</p>
<div className="mt-3">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
In Development
</span>
</div>
</div>
</div>
</div>
{/* Placeholder for future channels */}
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Alert Channels
</h3>
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
No alert channels configured yet
</p>
</div>
<div className="px-6 py-8 text-center">
<Bell 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 channels
</h3>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
Get started by adding your first alert channel.
</p>
</div>
</div>
</div>
);
};
export default AlertChannels;

View File

@@ -0,0 +1,49 @@
import { Plug } from "lucide-react";
import SettingsLayout from "../../components/SettingsLayout";
const Integrations = () => {
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>
{/* 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" />
</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>
</SettingsLayout>
);
};
export default Integrations;

View File

@@ -0,0 +1,112 @@
import { Bell, Settings } from "lucide-react";
const Notifications = () => {
return (
<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">
Notifications
</h1>
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
Configure notification preferences and alert rules
</p>
</div>
</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-primary-100 dark:bg-primary-900 rounded-lg flex items-center justify-center">
<Bell className="h-6 w-6 text-primary-600 dark:text-primary-400" />
</div>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Notification Rules Coming Soon
</h3>
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
We're working on adding comprehensive notification rules including
package updates, security alerts, system events, and custom
triggers.
</p>
<div className="mt-3">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
In Development
</span>
</div>
</div>
</div>
</div>
{/* Notification Settings Preview */}
<div className="grid grid-cols-1 md:grid-cols-1 gap-6">
{/* Package Updates */}
<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-3 mb-4">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
<Settings className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Package Updates
</h3>
</div>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-4">
Get notified when packages are updated on your hosts
</p>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-secondary-700 dark:text-secondary-300">
Critical Updates
</span>
<span className="text-xs text-secondary-500 dark:text-secondary-400">
Coming Soon
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-secondary-700 dark:text-secondary-300">
Security Patches
</span>
<span className="text-xs text-secondary-500 dark:text-secondary-400">
Coming Soon
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-secondary-700 dark:text-secondary-300">
Major Updates
</span>
<span className="text-xs text-secondary-500 dark:text-secondary-400">
Coming Soon
</span>
</div>
</div>
</div>
</div>
{/* Empty State */}
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Notification Rules
</h3>
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
No notification rules configured yet
</p>
</div>
<div className="px-6 py-8 text-center">
<Bell 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 rules
</h3>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
Get started by creating your first notification rule.
</p>
</div>
</div>
</div>
);
};
export default Notifications;

View File

@@ -0,0 +1,98 @@
import { Settings } from "lucide-react";
import SettingsLayout from "../../components/SettingsLayout";
const PatchManagement = () => {
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">
Patch Management
</h1>
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
Define and enforce policies for applying and monitoring patches
</p>
</div>
</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-primary-100 dark:bg-primary-900 rounded-lg flex items-center justify-center">
<Settings className="h-6 w-6 text-primary-600 dark:text-primary-400" />
</div>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Patch Management Coming Soon
</h3>
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
We are designing rule sets, approval workflows, and automated
patch policies to give you granular control over updates.
</p>
<div className="mt-3">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
In Development
</span>
</div>
</div>
</div>
</div>
{/* Patch Policy */}
<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-3 mb-4">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
<Settings className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Patch Policy
</h3>
</div>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-4">
Configure rule sets for patch management and monitoring
</p>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-secondary-700 dark:text-secondary-300">
Rule Sets
</span>
<span className="text-xs text-secondary-500 dark:text-secondary-400">
Coming Soon
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-secondary-700 dark:text-secondary-300">
Patch Approval Workflows
</span>
<span className="text-xs text-secondary-500 dark:text-secondary-400">
Coming Soon
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-secondary-700 dark:text-secondary-300">
Security Patch Priority
</span>
<span className="text-xs text-secondary-500 dark:text-secondary-400">
Coming Soon
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-secondary-700 dark:text-secondary-300">
Auto-Update Policies
</span>
<span className="text-xs text-secondary-500 dark:text-secondary-400">
Coming Soon
</span>
</div>
</div>
</div>
</div>
</SettingsLayout>
);
};
export default PatchManagement;

View File

@@ -0,0 +1,89 @@
import { Code, Settings } from "lucide-react";
import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import SettingsLayout from "../../components/SettingsLayout";
import AgentManagementTab from "../../components/settings/AgentManagementTab";
import AgentUpdatesTab from "../../components/settings/AgentUpdatesTab";
const SettingsAgentConfig = () => {
const location = useLocation();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState(() => {
// Set initial tab based on current route
if (location.pathname === "/settings/agent-version") return "management";
return "updates";
});
// Update active tab when route changes
useEffect(() => {
if (location.pathname === "/settings/agent-version") {
setActiveTab("management");
} else if (location.pathname === "/settings/agent-config") {
setActiveTab("updates");
}
}, [location.pathname]);
const tabs = [
{
id: "updates",
name: "Agent Updates",
icon: Settings,
href: "/settings/agent-config",
},
{
id: "management",
name: "Agent Version",
icon: Code,
href: "/settings/agent-version",
},
];
const renderTabContent = () => {
switch (activeTab) {
case "updates":
return <AgentUpdatesTab />;
case "management":
return <AgentManagementTab />;
default:
return <AgentUpdatesTab />;
}
};
return (
<SettingsLayout>
<div className="space-y-6">
{/* Tab Navigation */}
<div className="border-b border-secondary-200 dark:border-secondary-600">
<nav className="-mb-px flex space-x-8">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
type="button"
key={tab.id}
onClick={() => {
setActiveTab(tab.id);
navigate(tab.href);
}}
className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeTab === tab.id
? "border-primary-500 text-primary-600 dark:text-primary-400"
: "border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500"
}`}
>
<Icon className="h-4 w-4" />
{tab.name}
</button>
);
})}
</nav>
</div>
{/* Tab Content */}
<div className="mt-6">{renderTabContent()}</div>
</div>
</SettingsLayout>
);
};
export default SettingsAgentConfig;

View File

@@ -0,0 +1,599 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Edit, Plus, Server, Trash2 } from "lucide-react";
import { useEffect, useId, useState } from "react";
import { useNavigate } from "react-router-dom";
import SettingsLayout from "../../components/SettingsLayout";
import { hostGroupsAPI } from "../../utils/api";
const SettingsHostGroups = () => {
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedGroup, setSelectedGroup] = useState(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [groupToDelete, setGroupToDelete] = useState(null);
const queryClient = useQueryClient();
const navigate = useNavigate();
// Fetch host groups
const {
data: hostGroups,
isLoading,
error,
} = useQuery({
queryKey: ["hostGroups"],
queryFn: () => hostGroupsAPI.list().then((res) => res.data),
});
// Create host group mutation
const createMutation = useMutation({
mutationFn: (data) => hostGroupsAPI.create(data),
onSuccess: () => {
queryClient.invalidateQueries(["hostGroups"]);
setShowCreateModal(false);
},
onError: (error) => {
console.error("Failed to create host group:", error);
},
});
// Update host group mutation
const updateMutation = useMutation({
mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries(["hostGroups"]);
setShowEditModal(false);
setSelectedGroup(null);
},
onError: (error) => {
console.error("Failed to update host group:", error);
},
});
// Delete host group mutation
const deleteMutation = useMutation({
mutationFn: (id) => hostGroupsAPI.delete(id),
onSuccess: () => {
queryClient.invalidateQueries(["hostGroups"]);
setShowDeleteModal(false);
setGroupToDelete(null);
},
onError: (error) => {
console.error("Failed to delete host group:", error);
},
});
const handleCreate = (data) => {
createMutation.mutate(data);
};
const handleEdit = (group) => {
setSelectedGroup(group);
setShowEditModal(true);
};
const handleUpdate = (data) => {
updateMutation.mutate({ id: selectedGroup.id, data });
};
const _handleDeleteClick = (group) => {
setGroupToDelete(group);
setShowDeleteModal(true);
};
const handleDeleteConfirm = () => {
deleteMutation.mutate(groupToDelete.id);
};
const handleHostsClick = (groupId) => {
navigate(`/hosts?group=${groupId}`);
};
// Listen for delete modal trigger from edit modal
useEffect(() => {
const handleOpenDeleteModal = (event) => {
setGroupToDelete(event.detail);
setShowDeleteModal(true);
};
window.addEventListener("openDeleteHostGroupModal", handleOpenDeleteModal);
return () =>
window.removeEventListener(
"openDeleteHostGroupModal",
handleOpenDeleteModal,
);
}, []);
return (
<SettingsLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex justify-end items-center">
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="btn-primary flex items-center gap-2"
title="Create host group"
>
<Plus className="h-4 w-4" />
Create Group
</button>
</div>
{/* Host Groups Table */}
<div className="bg-white dark:bg-secondary-800 shadow overflow-hidden sm:rounded-lg">
<div className="overflow-x-auto">
<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">
Group
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Description
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Color
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Hosts
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{isLoading ? (
<tr>
<td colSpan="5" className="px-6 py-12 text-center">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
</td>
</tr>
) : error ? (
<tr>
<td colSpan="5" className="px-6 py-12 text-center">
<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 host groups
</h3>
<p className="text-sm text-danger-700 mt-1">
{error.message || "Failed to load host groups"}
</p>
</div>
</div>
</div>
</td>
</tr>
) : hostGroups && hostGroups.length > 0 ? (
hostGroups.map((group) => (
<tr
key={group.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div
className="w-3 h-3 rounded-full mr-3"
style={{ backgroundColor: group.color }}
/>
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{group.name}
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-secondary-500 dark:text-secondary-300">
{group.description || (
<span className="text-secondary-400 italic">
No description
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div
className="w-6 h-6 rounded border border-secondary-300"
style={{ backgroundColor: group.color }}
/>
<span className="ml-2 text-sm text-secondary-500 dark:text-secondary-300">
{group.color}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
type="button"
onClick={() => handleHostsClick(group.id)}
className="flex items-center text-sm text-secondary-500 dark:text-secondary-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
title={`View hosts in ${group.name}`}
>
<Server className="h-4 w-4 mr-2" />
{group._count.hosts} host
{group._count.hosts !== 1 ? "s" : ""}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
type="button"
onClick={() => handleEdit(group)}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
title="Edit group"
>
<Edit className="h-4 w-4" />
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan="5" className="px-6 py-12 text-center">
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
No host groups found
</p>
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
Click "Create Group" to create the first host group
</p>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Create Modal */}
{showCreateModal && (
<CreateHostGroupModal
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreate}
isLoading={createMutation.isPending}
/>
)}
{/* Edit Modal */}
{showEditModal && selectedGroup && (
<EditHostGroupModal
group={selectedGroup}
onClose={() => {
setShowEditModal(false);
setSelectedGroup(null);
}}
onSubmit={handleUpdate}
isLoading={updateMutation.isPending}
/>
)}
{/* Delete Confirmation Modal */}
{showDeleteModal && groupToDelete && (
<DeleteHostGroupModal
group={groupToDelete}
onClose={() => {
setShowDeleteModal(false);
setGroupToDelete(null);
}}
onConfirm={handleDeleteConfirm}
isLoading={deleteMutation.isPending}
/>
)}
</div>
</SettingsLayout>
);
};
// Create Host Group Modal
const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
const nameId = useId();
const descriptionId = useId();
const colorId = useId();
const [formData, setFormData] = useState({
name: "",
description: "",
color: "#3B82F6",
});
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(formData);
};
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
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 p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
Create Host Group
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={nameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Name *
</label>
<input
type="text"
id={nameId}
name="name"
value={formData.name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="e.g., Production Servers"
/>
</div>
<div>
<label
htmlFor={descriptionId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Description
</label>
<textarea
id={descriptionId}
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="Optional description for this group"
/>
</div>
<div>
<label
htmlFor={colorId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
id={colorId}
name="color"
value={formData.color}
onChange={handleChange}
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
/>
<input
type="text"
value={formData.color}
onChange={handleChange}
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="#3B82F6"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button type="submit" className="btn-primary" disabled={isLoading}>
{isLoading ? "Creating..." : "Create Group"}
</button>
</div>
</form>
</div>
</div>
);
};
// Edit Host Group Modal
const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
const editNameId = useId();
const editDescriptionId = useId();
const editColorId = useId();
const [formData, setFormData] = useState({
name: group.name,
description: group.description || "",
color: group.color || "#3B82F6",
});
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(formData);
};
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
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 p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
Edit Host Group
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={editNameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Name *
</label>
<input
type="text"
id={editNameId}
name="name"
value={formData.name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="e.g., Production Servers"
/>
</div>
<div>
<label
htmlFor={editDescriptionId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Description
</label>
<textarea
id={editDescriptionId}
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="Optional description for this group"
/>
</div>
<div>
<label
htmlFor={editColorId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
id={editColorId}
name="color"
value={formData.color}
onChange={handleChange}
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
/>
<input
type="text"
value={formData.color}
onChange={handleChange}
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="#3B82F6"
/>
</div>
</div>
<div className="flex justify-between pt-4">
<button
type="button"
onClick={() => {
onClose();
// Trigger delete modal
window.dispatchEvent(
new CustomEvent("openDeleteHostGroupModal", {
detail: group,
}),
);
}}
className="btn-danger"
disabled={isLoading}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Group
</button>
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={isLoading}
>
{isLoading ? "Updating..." : "Update Group"}
</button>
</div>
</div>
</form>
</div>
</div>
);
};
// Delete Confirmation Modal
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
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 p-6 w-full max-w-md">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger-600" />
</div>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Delete Host Group
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300">
This action cannot be undone
</p>
</div>
</div>
<div className="mb-6">
<p className="text-secondary-700 dark:text-secondary-200">
Are you sure you want to delete the host group{" "}
<span className="font-semibold">"{group.name}"</span>?
</p>
{group._count.hosts > 0 && (
<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-md">
<p className="text-sm text-blue-800">
<strong>Note:</strong> This group contains {group._count.hosts}{" "}
host
{group._count.hosts !== 1 ? "s" : ""}. These hosts will be moved
to "No group" after deletion.
</p>
</div>
)}
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
className="btn-danger"
disabled={isLoading}
>
{isLoading ? "Deleting..." : "Delete Group"}
</button>
</div>
</div>
</div>
);
};
export default SettingsHostGroups;

View File

@@ -0,0 +1,96 @@
import { Code, Server } from "lucide-react";
import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import SettingsLayout from "../../components/SettingsLayout";
import ProtocolUrlTab from "../../components/settings/ProtocolUrlTab";
import VersionUpdateTab from "../../components/settings/VersionUpdateTab";
const SettingsServerConfig = () => {
const location = useLocation();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState(() => {
// 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/server-config/version")
return "version";
return "protocol";
});
// Update active tab when route changes
useEffect(() => {
if (location.pathname === "/settings/server-version") {
setActiveTab("version");
} else if (location.pathname === "/settings/server-url") {
setActiveTab("protocol");
} else if (location.pathname === "/settings/server-config/version") {
setActiveTab("version");
} else if (location.pathname === "/settings/server-config") {
setActiveTab("protocol");
}
}, [location.pathname]);
const tabs = [
{
id: "protocol",
name: "URL Config",
icon: Server,
href: "/settings/server-url",
},
{
id: "version",
name: "Server Version",
icon: Code,
href: "/settings/server-version",
},
];
const renderTabContent = () => {
switch (activeTab) {
case "protocol":
return <ProtocolUrlTab />;
case "version":
return <VersionUpdateTab />;
default:
return <ProtocolUrlTab />;
}
};
return (
<SettingsLayout>
<div className="space-y-6">
{/* Tab Navigation */}
<div className="border-b border-secondary-200 dark:border-secondary-600">
<nav className="-mb-px flex space-x-8">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
type="button"
key={tab.id}
onClick={() => {
setActiveTab(tab.id);
navigate(tab.href);
}}
className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeTab === tab.id
? "border-primary-500 text-primary-600 dark:text-primary-400"
: "border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500"
}`}
>
<Icon className="h-4 w-4" />
{tab.name}
</button>
);
})}
</nav>
</div>
{/* Tab Content */}
<div className="mt-6">{renderTabContent()}</div>
</div>
</SettingsLayout>
);
};
export default SettingsServerConfig;

View File

@@ -0,0 +1,107 @@
import { Plus, Shield, Users } from "lucide-react";
import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import SettingsLayout from "../../components/SettingsLayout";
import RolesTab from "../../components/settings/RolesTab";
import UsersTab from "../../components/settings/UsersTab";
const SettingsUsers = () => {
const location = useLocation();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState(() => {
// Set initial tab based on current route
if (location.pathname === "/settings/roles") return "roles";
return "users";
});
const tabs = [
{ id: "users", name: "Users", icon: Users, href: "/settings/users" },
{ id: "roles", name: "Roles", icon: Shield, href: "/settings/roles" },
];
// Update active tab when route changes
useEffect(() => {
if (location.pathname === "/settings/roles") {
setActiveTab("roles");
} else if (location.pathname === "/settings/users") {
setActiveTab("users");
}
}, [location.pathname]);
const renderTabContent = () => {
switch (activeTab) {
case "users":
return <UsersTab />;
case "roles":
return <RolesTab />;
default:
return <UsersTab />;
}
};
return (
<SettingsLayout>
<div className="space-y-6">
{/* Tab Navigation */}
<div className="border-b border-secondary-200 dark:border-secondary-600">
<nav className="-mb-px flex items-center justify-between">
<div className="flex space-x-8">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
type="button"
key={tab.id}
onClick={() => {
setActiveTab(tab.id);
navigate(tab.href);
}}
className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeTab === tab.id
? "border-primary-500 text-primary-600 dark:text-primary-400"
: "border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500"
}`}
>
<Icon className="h-4 w-4" />
{tab.name}
</button>
);
})}
</div>
{activeTab === "users" && (
<button
type="button"
onClick={() =>
window.dispatchEvent(new Event("openAddUserModal"))
}
className="btn-primary flex items-center gap-2"
title="Add user"
>
<Plus className="h-4 w-4" />
Add User
</button>
)}
{activeTab === "roles" && (
<button
type="button"
onClick={() =>
window.dispatchEvent(new Event("openAddRoleModal"))
}
className="btn-primary flex items-center gap-2"
title="Add role"
>
<Plus className="h-4 w-4" />
Add Role
</button>
)}
</nav>
</div>
{/* Tab Content */}
<div className="mt-6">{renderTabContent()}</div>
</div>
</SettingsLayout>
);
};
export default SettingsUsers;

View File

@@ -74,6 +74,10 @@ export const adminHostsAPI = {
api.patch(`/hosts/${hostId}/friendly-name`, {
friendly_name: friendlyName,
}),
updateNotes: (hostId, notes) =>
api.patch(`/hosts/${hostId}/notes`, {
notes: notes,
}),
};
// Host Groups API