mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-10-23 07:42:05 +00:00
1.2.7 Release
Please see the release notes.
This commit is contained in:
17
.github/workflows/app_build.yml
vendored
Normal file
17
.github/workflows/app_build.yml
vendored
Normal 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 }}
|
21
.github/workflows/docker.yml
vendored
21
.github/workflows/docker.yml
vendored
@@ -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
2
.gitignore
vendored
@@ -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
|
||||
|
@@ -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"
|
||||
|
@@ -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)
|
||||
|
@@ -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'
|
||||
|
@@ -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
|
||||
|
@@ -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");
|
@@ -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;
|
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "hosts" ADD COLUMN "notes" TEXT;
|
||||
|
31
backend/prisma/migrations/add_user_sessions/migration.sql
Normal file
31
backend/prisma/migrations/add_user_sessions/migration.sql
Normal 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;
|
||||
|
@@ -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])
|
||||
}
|
||||
|
@@ -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") {
|
||||
|
@@ -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;
|
||||
|
@@ -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,
|
||||
|
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -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;
|
||||
|
@@ -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();
|
||||
}
|
||||
|
@@ -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);
|
||||
|
@@ -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(),
|
||||
},
|
||||
});
|
||||
|
319
backend/src/utils/session_manager.js
Normal file
319
backend/src/utils/session_manager.js
Normal 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,
|
||||
};
|
122
docker/README.md
122
docker/README.md
@@ -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
|
||||
```
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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"
|
||||
|
@@ -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>
|
||||
}
|
||||
|
@@ -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"
|
||||
}`}
|
||||
|
273
frontend/src/components/SettingsLayout.jsx
Normal file
273
frontend/src/components/SettingsLayout.jsx
Normal 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;
|
379
frontend/src/components/settings/AgentManagementTab.jsx
Normal file
379
frontend/src/components/settings/AgentManagementTab.jsx
Normal 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 # PatchMon Agent Script VERSION="1.0.0" # 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;
|
453
frontend/src/components/settings/AgentUpdatesTab.jsx
Normal file
453
frontend/src/components/settings/AgentUpdatesTab.jsx
Normal 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;
|
305
frontend/src/components/settings/ProtocolUrlTab.jsx
Normal file
305
frontend/src/components/settings/ProtocolUrlTab.jsx
Normal 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;
|
568
frontend/src/components/settings/RolesTab.jsx
Normal file
568
frontend/src/components/settings/RolesTab.jsx
Normal 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;
|
896
frontend/src/components/settings/UsersTab.jsx
Normal file
896
frontend/src/components/settings/UsersTab.jsx
Normal 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;
|
573
frontend/src/components/settings/VersionUpdateTab.jsx
Normal file
573
frontend/src/components/settings/VersionUpdateTab.jsx
Normal 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;
|
@@ -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"
|
||||
|
@@ -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
|
||||
|
@@ -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("/");
|
||||
|
@@ -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("");
|
||||
|
@@ -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
|
||||
}}
|
||||
|
67
frontend/src/pages/settings/AlertChannels.jsx
Normal file
67
frontend/src/pages/settings/AlertChannels.jsx
Normal 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;
|
49
frontend/src/pages/settings/Integrations.jsx
Normal file
49
frontend/src/pages/settings/Integrations.jsx
Normal 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;
|
112
frontend/src/pages/settings/Notifications.jsx
Normal file
112
frontend/src/pages/settings/Notifications.jsx
Normal 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;
|
98
frontend/src/pages/settings/PatchManagement.jsx
Normal file
98
frontend/src/pages/settings/PatchManagement.jsx
Normal 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;
|
89
frontend/src/pages/settings/SettingsAgentConfig.jsx
Normal file
89
frontend/src/pages/settings/SettingsAgentConfig.jsx
Normal 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;
|
599
frontend/src/pages/settings/SettingsHostGroups.jsx
Normal file
599
frontend/src/pages/settings/SettingsHostGroups.jsx
Normal 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;
|
96
frontend/src/pages/settings/SettingsServerConfig.jsx
Normal file
96
frontend/src/pages/settings/SettingsServerConfig.jsx
Normal 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;
|
107
frontend/src/pages/settings/SettingsUsers.jsx
Normal file
107
frontend/src/pages/settings/SettingsUsers.jsx
Normal 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;
|
@@ -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
|
||||
|
Reference in New Issue
Block a user