mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-20 14:38:30 +00:00
Merge pull request #320 from PatchMon/feature/alpine
added reboot required flag
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,7 +2,9 @@
|
||||
|
||||
# PatchMon Agent Removal Script
|
||||
# POSIX-compliant shell script (works with dash, ash, bash, etc.)
|
||||
# Usage: curl -s {PATCHMON_URL}/api/v1/hosts/remove | sh
|
||||
# Usage: curl -s {PATCHMON_URL}/api/v1/hosts/remove | sudo sh
|
||||
# curl -s {PATCHMON_URL}/api/v1/hosts/remove | sudo REMOVE_BACKUPS=1 sh
|
||||
# curl -s {PATCHMON_URL}/api/v1/hosts/remove | sudo SILENT=1 sh
|
||||
# This script completely removes PatchMon from the system
|
||||
|
||||
set -e
|
||||
@@ -12,12 +14,30 @@ set -e
|
||||
# future (left for consistency with install script).
|
||||
CURL_FLAGS=""
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
# Detect if running in silent mode (only with explicit SILENT env var)
|
||||
SILENT_MODE=0
|
||||
if [ -n "$SILENT" ]; then
|
||||
SILENT_MODE=1
|
||||
fi
|
||||
|
||||
# Check if backup files should be removed (default: preserve for safety)
|
||||
# Usage: REMOVE_BACKUPS=1 when piping the script
|
||||
REMOVE_BACKUPS="${REMOVE_BACKUPS:-0}"
|
||||
|
||||
# Colors for output (disabled in silent mode)
|
||||
if [ "$SILENT_MODE" -eq 0 ]; then
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
else
|
||||
RED=''
|
||||
GREEN=''
|
||||
YELLOW=''
|
||||
BLUE=''
|
||||
NC=''
|
||||
fi
|
||||
|
||||
# Functions
|
||||
error() {
|
||||
@@ -26,15 +46,21 @@ error() {
|
||||
}
|
||||
|
||||
info() {
|
||||
printf "%b\n" "${BLUE}ℹ️ $1${NC}"
|
||||
if [ "$SILENT_MODE" -eq 0 ]; then
|
||||
printf "%b\n" "${BLUE}ℹ️ $1${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
success() {
|
||||
printf "%b\n" "${GREEN}✅ $1${NC}"
|
||||
if [ "$SILENT_MODE" -eq 0 ]; then
|
||||
printf "%b\n" "${GREEN}✅ $1${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
warning() {
|
||||
printf "%b\n" "${YELLOW}⚠️ $1${NC}"
|
||||
if [ "$SILENT_MODE" -eq 0 ]; then
|
||||
printf "%b\n" "${YELLOW}⚠️ $1${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if running as root
|
||||
@@ -43,7 +69,7 @@ if [ "$(id -u)" -ne 0 ]; then
|
||||
fi
|
||||
|
||||
info "🗑️ Starting PatchMon Agent Removal..."
|
||||
echo ""
|
||||
[ "$SILENT_MODE" -eq 0 ] && echo ""
|
||||
|
||||
# Step 1: Stop systemd/OpenRC service if it exists
|
||||
info "🛑 Stopping PatchMon service..."
|
||||
@@ -51,24 +77,75 @@ SERVICE_STOPPED=0
|
||||
|
||||
# Check for systemd service
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
info "📋 Checking systemd service status..."
|
||||
|
||||
# Check if service is active
|
||||
if systemctl is-active --quiet patchmon-agent.service 2>/dev/null; then
|
||||
warning "Stopping systemd service..."
|
||||
systemctl stop patchmon-agent.service || true
|
||||
SERVICE_STOPPED=1
|
||||
SERVICE_STATUS=$(systemctl is-active patchmon-agent.service 2>/dev/null || echo "unknown")
|
||||
warning "Service is active (status: $SERVICE_STATUS). Stopping it now..."
|
||||
if systemctl stop patchmon-agent.service 2>/dev/null; then
|
||||
success "✓ Service stopped successfully"
|
||||
SERVICE_STOPPED=1
|
||||
else
|
||||
warning "✗ Failed to stop service (continuing anyway...)"
|
||||
fi
|
||||
# Verify it stopped
|
||||
sleep 1
|
||||
if systemctl is-active --quiet patchmon-agent.service 2>/dev/null; then
|
||||
warning "⚠️ Service is STILL ACTIVE after stop command!"
|
||||
else
|
||||
info "✓ Verified: Service is no longer active"
|
||||
fi
|
||||
else
|
||||
info "Service is not active"
|
||||
fi
|
||||
|
||||
# Check if service is enabled
|
||||
if systemctl is-enabled --quiet patchmon-agent.service 2>/dev/null; then
|
||||
warning "Disabling systemd service..."
|
||||
systemctl disable patchmon-agent.service || true
|
||||
ENABLED_STATUS=$(systemctl is-enabled patchmon-agent.service 2>/dev/null || echo "unknown")
|
||||
warning "Service is enabled (status: $ENABLED_STATUS). Disabling it now..."
|
||||
if systemctl disable patchmon-agent.service 2>/dev/null; then
|
||||
success "✓ Service disabled successfully"
|
||||
else
|
||||
warning "✗ Failed to disable service (may already be disabled)"
|
||||
fi
|
||||
else
|
||||
info "Service is not enabled"
|
||||
fi
|
||||
|
||||
# Check for service file
|
||||
if [ -f "/etc/systemd/system/patchmon-agent.service" ]; then
|
||||
warning "Removing systemd service file..."
|
||||
rm -f /etc/systemd/system/patchmon-agent.service
|
||||
systemctl daemon-reload || true
|
||||
success "Systemd service removed"
|
||||
warning "Found service file: /etc/systemd/system/patchmon-agent.service"
|
||||
info "Removing service file..."
|
||||
if rm -f /etc/systemd/system/patchmon-agent.service 2>/dev/null; then
|
||||
success "✓ Service file removed"
|
||||
else
|
||||
warning "✗ Failed to remove service file (check permissions)"
|
||||
fi
|
||||
|
||||
info "Reloading systemd daemon..."
|
||||
if systemctl daemon-reload 2>/dev/null; then
|
||||
success "✓ Systemd daemon reloaded"
|
||||
else
|
||||
warning "✗ Failed to reload systemd daemon"
|
||||
fi
|
||||
|
||||
SERVICE_STOPPED=1
|
||||
|
||||
# Verify the file is gone
|
||||
if [ -f "/etc/systemd/system/patchmon-agent.service" ]; then
|
||||
warning "⚠️ Service file STILL EXISTS after removal!"
|
||||
else
|
||||
info "✓ Verified: Service file removed"
|
||||
fi
|
||||
else
|
||||
info "Service file not found at /etc/systemd/system/patchmon-agent.service"
|
||||
fi
|
||||
|
||||
# Final status check
|
||||
info "📊 Final systemd status check..."
|
||||
FINAL_STATUS=$(systemctl is-active patchmon-agent.service 2>&1 || echo "not-found")
|
||||
info "Service status: $FINAL_STATUS"
|
||||
fi
|
||||
|
||||
# Check for OpenRC service (Alpine Linux)
|
||||
@@ -93,11 +170,47 @@ if command -v rc-service >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
# Stop any remaining running processes (legacy or manual starts)
|
||||
info "🔍 Checking for running PatchMon processes..."
|
||||
if pgrep -f "patchmon-agent" >/dev/null; then
|
||||
warning "Found running PatchMon processes, stopping them..."
|
||||
pkill -f "patchmon-agent" || true
|
||||
PROCESS_COUNT=$(pgrep -f "patchmon-agent" | wc -l | tr -d ' ')
|
||||
warning "Found $PROCESS_COUNT running PatchMon process(es)"
|
||||
|
||||
# Show process details
|
||||
if [ "$SILENT_MODE" -eq 0 ]; then
|
||||
info "Process details:"
|
||||
ps aux | grep "[p]atchmon-agent" | while IFS= read -r line; do
|
||||
echo " $line"
|
||||
done
|
||||
fi
|
||||
|
||||
warning "Sending SIGTERM to all patchmon-agent processes..."
|
||||
if pkill -f "patchmon-agent" 2>/dev/null; then
|
||||
success "✓ Sent SIGTERM signal"
|
||||
else
|
||||
warning "Failed to send SIGTERM (processes may have already stopped)"
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
|
||||
# Check if processes still exist
|
||||
if pgrep -f "patchmon-agent" >/dev/null; then
|
||||
REMAINING=$(pgrep -f "patchmon-agent" | wc -l | tr -d ' ')
|
||||
warning "⚠️ $REMAINING process(es) still running! Sending SIGKILL..."
|
||||
pkill -9 -f "patchmon-agent" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
if pgrep -f "patchmon-agent" >/dev/null; then
|
||||
warning "⚠️ CRITICAL: Processes still running after SIGKILL!"
|
||||
else
|
||||
success "✓ All processes terminated"
|
||||
fi
|
||||
else
|
||||
success "✓ All processes stopped successfully"
|
||||
fi
|
||||
|
||||
SERVICE_STOPPED=1
|
||||
else
|
||||
info "No running PatchMon processes found"
|
||||
fi
|
||||
|
||||
if [ "$SERVICE_STOPPED" -eq 1 ]; then
|
||||
@@ -159,11 +272,13 @@ info "📁 Removing configuration files..."
|
||||
if [ -d "/etc/patchmon" ]; then
|
||||
warning "Removing configuration directory: /etc/patchmon"
|
||||
|
||||
# Show what's being removed
|
||||
info "📋 Files in /etc/patchmon:"
|
||||
ls -la /etc/patchmon/ 2>/dev/null | grep -v "^total" | while read -r line; do
|
||||
echo " $line"
|
||||
done
|
||||
# Show what's being removed (only in verbose mode)
|
||||
if [ "$SILENT_MODE" -eq 0 ]; then
|
||||
info "📋 Files in /etc/patchmon:"
|
||||
ls -la /etc/patchmon/ 2>/dev/null | grep -v "^total" | while read -r line; do
|
||||
echo " $line"
|
||||
done
|
||||
fi
|
||||
|
||||
# Remove the directory
|
||||
rm -rf /etc/patchmon
|
||||
@@ -182,83 +297,105 @@ else
|
||||
info "Log file not found"
|
||||
fi
|
||||
|
||||
# Step 6: Clean up backup files (optional)
|
||||
info "🧹 Cleaning up backup files..."
|
||||
# Step 6: Clean up backup files
|
||||
info "🧹 Checking backup files..."
|
||||
BACKUP_COUNT=0
|
||||
BACKUP_REMOVED=0
|
||||
|
||||
# Count credential backups
|
||||
CRED_BACKUPS=$(ls /etc/patchmon/credentials.backup.* 2>/dev/null | wc -l || echo "0")
|
||||
if [ "$CRED_BACKUPS" -gt 0 ]; then
|
||||
BACKUP_COUNT=$((BACKUP_COUNT + CRED_BACKUPS))
|
||||
fi
|
||||
if [ "$REMOVE_BACKUPS" -eq 1 ]; then
|
||||
info "Removing backup files (REMOVE_BACKUPS=1)..."
|
||||
|
||||
# Count agent backups
|
||||
AGENT_BACKUPS=$(ls /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | wc -l || echo "0")
|
||||
if [ "$AGENT_BACKUPS" -gt 0 ]; then
|
||||
BACKUP_COUNT=$((BACKUP_COUNT + AGENT_BACKUPS))
|
||||
fi
|
||||
|
||||
# Count log backups
|
||||
LOG_BACKUPS=$(ls /var/log/patchmon-agent.log.old.* 2>/dev/null | wc -l || echo "0")
|
||||
if [ "$LOG_BACKUPS" -gt 0 ]; then
|
||||
BACKUP_COUNT=$((BACKUP_COUNT + LOG_BACKUPS))
|
||||
fi
|
||||
|
||||
if [ "$BACKUP_COUNT" -gt 0 ]; then
|
||||
warning "Found $BACKUP_COUNT backup files"
|
||||
echo ""
|
||||
printf "%b\n" "${YELLOW}📋 Backup files found:${NC}"
|
||||
|
||||
# Show credential backups
|
||||
if [ "$CRED_BACKUPS" -gt 0 ]; then
|
||||
echo " Credential backups:"
|
||||
ls /etc/patchmon/credentials.backup.* 2>/dev/null | while read -r file; do
|
||||
echo " • $file"
|
||||
done
|
||||
# Remove credential backups (already removed with /etc/patchmon directory, but check anyway)
|
||||
if ls /etc/patchmon/credentials.backup.* >/dev/null 2>&1; then
|
||||
CRED_BACKUPS=$(ls /etc/patchmon/credentials.backup.* 2>/dev/null | wc -l | tr -d ' ')
|
||||
warning "Removing $CRED_BACKUPS credential backup file(s)..."
|
||||
rm -f /etc/patchmon/credentials.backup.*
|
||||
BACKUP_COUNT=$((BACKUP_COUNT + CRED_BACKUPS))
|
||||
BACKUP_REMOVED=1
|
||||
fi
|
||||
|
||||
# Show agent backups
|
||||
if [ "$AGENT_BACKUPS" -gt 0 ]; then
|
||||
echo " Agent script backups:"
|
||||
ls /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | while read -r file; do
|
||||
echo " • $file"
|
||||
done
|
||||
# Remove config backups (already removed with /etc/patchmon directory, but check anyway)
|
||||
if ls /etc/patchmon/*.backup.* >/dev/null 2>&1; then
|
||||
CONFIG_BACKUPS=$(ls /etc/patchmon/*.backup.* 2>/dev/null | wc -l | tr -d ' ')
|
||||
warning "Removing $CONFIG_BACKUPS config backup file(s)..."
|
||||
rm -f /etc/patchmon/*.backup.*
|
||||
BACKUP_COUNT=$((BACKUP_COUNT + CONFIG_BACKUPS))
|
||||
BACKUP_REMOVED=1
|
||||
fi
|
||||
|
||||
# Show log backups
|
||||
if [ "$LOG_BACKUPS" -gt 0 ]; then
|
||||
echo " Log file backups:"
|
||||
ls /var/log/patchmon-agent.log.old.* 2>/dev/null | while read -r file; do
|
||||
echo " • $file"
|
||||
done
|
||||
# Remove Go agent backups
|
||||
if ls /usr/local/bin/patchmon-agent.backup.* >/dev/null 2>&1; then
|
||||
GO_AGENT_BACKUPS=$(ls /usr/local/bin/patchmon-agent.backup.* 2>/dev/null | wc -l | tr -d ' ')
|
||||
warning "Removing $GO_AGENT_BACKUPS Go agent backup file(s)..."
|
||||
rm -f /usr/local/bin/patchmon-agent.backup.*
|
||||
BACKUP_COUNT=$((BACKUP_COUNT + GO_AGENT_BACKUPS))
|
||||
BACKUP_REMOVED=1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
printf "%b\n" "${BLUE}💡 Note: Backup files are preserved for safety${NC}"
|
||||
printf "%b\n" "${BLUE}💡 You can remove them manually if not needed${NC}"
|
||||
# Remove legacy shell agent backups
|
||||
if ls /usr/local/bin/patchmon-agent.sh.backup.* >/dev/null 2>&1; then
|
||||
SHELL_AGENT_BACKUPS=$(ls /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | wc -l | tr -d ' ')
|
||||
warning "Removing $SHELL_AGENT_BACKUPS legacy agent backup file(s)..."
|
||||
rm -f /usr/local/bin/patchmon-agent.sh.backup.*
|
||||
BACKUP_COUNT=$((BACKUP_COUNT + SHELL_AGENT_BACKUPS))
|
||||
BACKUP_REMOVED=1
|
||||
fi
|
||||
|
||||
# Remove log backups
|
||||
if ls /var/log/patchmon-agent.log.old.* >/dev/null 2>&1; then
|
||||
LOG_BACKUPS=$(ls /var/log/patchmon-agent.log.old.* 2>/dev/null | wc -l | tr -d ' ')
|
||||
warning "Removing $LOG_BACKUPS log backup file(s)..."
|
||||
rm -f /var/log/patchmon-agent.log.old.*
|
||||
BACKUP_COUNT=$((BACKUP_COUNT + LOG_BACKUPS))
|
||||
BACKUP_REMOVED=1
|
||||
fi
|
||||
|
||||
if [ "$BACKUP_REMOVED" -eq 1 ]; then
|
||||
success "Removed $BACKUP_COUNT backup file(s)"
|
||||
else
|
||||
info "No backup files found to remove"
|
||||
fi
|
||||
else
|
||||
info "No backup files found"
|
||||
# Just count backup files without removing
|
||||
CRED_BACKUPS=0
|
||||
CONFIG_BACKUPS=0
|
||||
GO_AGENT_BACKUPS=0
|
||||
SHELL_AGENT_BACKUPS=0
|
||||
LOG_BACKUPS=0
|
||||
|
||||
if ls /etc/patchmon/credentials.backup.* >/dev/null 2>&1; then
|
||||
CRED_BACKUPS=$(ls /etc/patchmon/credentials.backup.* 2>/dev/null | wc -l | tr -d ' ')
|
||||
fi
|
||||
|
||||
if ls /etc/patchmon/*.backup.* >/dev/null 2>&1; then
|
||||
CONFIG_BACKUPS=$(ls /etc/patchmon/*.backup.* 2>/dev/null | wc -l | tr -d ' ')
|
||||
fi
|
||||
|
||||
if ls /usr/local/bin/patchmon-agent.backup.* >/dev/null 2>&1; then
|
||||
GO_AGENT_BACKUPS=$(ls /usr/local/bin/patchmon-agent.backup.* 2>/dev/null | wc -l | tr -d ' ')
|
||||
fi
|
||||
|
||||
if ls /usr/local/bin/patchmon-agent.sh.backup.* >/dev/null 2>&1; then
|
||||
SHELL_AGENT_BACKUPS=$(ls /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | wc -l | tr -d ' ')
|
||||
fi
|
||||
|
||||
if ls /var/log/patchmon-agent.log.old.* >/dev/null 2>&1; then
|
||||
LOG_BACKUPS=$(ls /var/log/patchmon-agent.log.old.* 2>/dev/null | wc -l | tr -d ' ')
|
||||
fi
|
||||
|
||||
BACKUP_COUNT=$((CRED_BACKUPS + CONFIG_BACKUPS + GO_AGENT_BACKUPS + SHELL_AGENT_BACKUPS + LOG_BACKUPS))
|
||||
|
||||
if [ "$BACKUP_COUNT" -gt 0 ]; then
|
||||
info "Found $BACKUP_COUNT backup file(s) - preserved for safety"
|
||||
if [ "$SILENT_MODE" -eq 0 ]; then
|
||||
printf "%b\n" "${BLUE}💡 To remove backups, run with: REMOVE_BACKUPS=1${NC}"
|
||||
fi
|
||||
else
|
||||
info "No backup files found"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 7: Remove dependencies (optional)
|
||||
info "📦 Checking for PatchMon-specific dependencies..."
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
warning "jq is installed (used by PatchMon)"
|
||||
printf "%b\n" "${BLUE}💡 Note: jq may be used by other applications${NC}"
|
||||
printf "%b\n" "${BLUE}💡 Consider keeping it unless you're sure it's not needed${NC}"
|
||||
else
|
||||
info "jq not found"
|
||||
fi
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
warning "curl is installed (used by PatchMon)"
|
||||
printf "%b\n" "${BLUE}💡 Note: curl is commonly used by many applications${NC}"
|
||||
printf "%b\n" "${BLUE}💡 Consider keeping it unless you're sure it's not needed${NC}"
|
||||
else
|
||||
info "curl not found"
|
||||
fi
|
||||
|
||||
# Step 8: Final verification
|
||||
# Step 7: Final verification
|
||||
info "🔍 Verifying removal..."
|
||||
REMAINING_FILES=0
|
||||
|
||||
@@ -294,21 +431,30 @@ if [ "$REMAINING_FILES" -eq 0 ]; then
|
||||
success "✅ PatchMon has been completely removed from the system!"
|
||||
else
|
||||
warning "⚠️ Some PatchMon files may still remain ($REMAINING_FILES items)"
|
||||
printf "%b\n" "${BLUE}💡 You may need to remove them manually${NC}"
|
||||
if [ "$SILENT_MODE" -eq 0 ]; then
|
||||
printf "%b\n" "${BLUE}💡 You may need to remove them manually${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
printf "%b\n" "${GREEN}📋 Removal Summary:${NC}"
|
||||
echo " • Agent binaries: Removed"
|
||||
echo " • System services: Removed (systemd/OpenRC)"
|
||||
echo " • Configuration files: Removed"
|
||||
echo " • Log files: Removed"
|
||||
echo " • Crontab entries: Removed"
|
||||
echo " • Running processes: Stopped"
|
||||
echo " • Backup files: Preserved (if any)"
|
||||
echo ""
|
||||
printf "%b\n" "${BLUE}🔧 Manual cleanup (if needed):${NC}"
|
||||
echo " • Remove backup files: rm /etc/patchmon/credentials.backup.* /usr/local/bin/patchmon-agent.sh.backup.* /var/log/patchmon-agent.log.old.*"
|
||||
echo " • Remove dependencies: apt remove jq curl (if not needed by other apps)"
|
||||
echo ""
|
||||
if [ "$SILENT_MODE" -eq 0 ]; then
|
||||
echo ""
|
||||
printf "%b\n" "${GREEN}📋 Removal Summary:${NC}"
|
||||
echo " • Agent binaries: Removed"
|
||||
echo " • System services: Removed (systemd/OpenRC)"
|
||||
echo " • Configuration files: Removed"
|
||||
echo " • Log files: Removed"
|
||||
echo " • Crontab entries: Removed"
|
||||
echo " • Running processes: Stopped"
|
||||
if [ "$REMOVE_BACKUPS" -eq 1 ]; then
|
||||
echo " • Backup files: Removed"
|
||||
else
|
||||
echo " • Backup files: Preserved (${BACKUP_COUNT} files)"
|
||||
fi
|
||||
echo ""
|
||||
if [ "$REMOVE_BACKUPS" -eq 0 ] && [ "$BACKUP_COUNT" -gt 0 ]; then
|
||||
printf "%b\n" "${BLUE}🔧 Manual cleanup (if needed):${NC}"
|
||||
echo " • Remove backup files: curl -s \${PATCHMON_URL}/api/v1/hosts/remove | sudo REMOVE_BACKUPS=1 sh"
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
success "🎉 PatchMon removal completed!"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "hosts" ADD COLUMN "needs_reboot" BOOLEAN DEFAULT false;
|
||||
ALTER TABLE "hosts" ADD COLUMN "installed_kernel_version" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "hosts_needs_reboot_idx" ON "hosts"("needs_reboot");
|
||||
|
||||
@@ -110,6 +110,7 @@ model hosts {
|
||||
swap_size Int?
|
||||
system_uptime String?
|
||||
notes String?
|
||||
needs_reboot Boolean? @default(false)
|
||||
host_packages host_packages[]
|
||||
host_repositories host_repositories[]
|
||||
host_group_memberships host_group_memberships[]
|
||||
@@ -121,6 +122,7 @@ model hosts {
|
||||
@@index([machine_id])
|
||||
@@index([friendly_name])
|
||||
@@index([hostname])
|
||||
@@index([needs_reboot])
|
||||
}
|
||||
|
||||
model packages {
|
||||
|
||||
@@ -92,58 +92,63 @@ async function createDefaultDashboardPreferences(userId, userRole = "user") {
|
||||
requiredPermission: "can_view_hosts",
|
||||
order: 5,
|
||||
},
|
||||
{
|
||||
cardId: "hostsNeedingReboot",
|
||||
requiredPermission: "can_view_hosts",
|
||||
order: 6,
|
||||
},
|
||||
|
||||
// Repository-related cards
|
||||
{ cardId: "totalRepos", requiredPermission: "can_view_hosts", order: 6 },
|
||||
{ cardId: "totalRepos", requiredPermission: "can_view_hosts", order: 7 },
|
||||
|
||||
// User management cards (admin only)
|
||||
{ cardId: "totalUsers", requiredPermission: "can_view_users", order: 7 },
|
||||
{ cardId: "totalUsers", requiredPermission: "can_view_users", order: 8 },
|
||||
|
||||
// System/Report cards
|
||||
{
|
||||
cardId: "osDistribution",
|
||||
requiredPermission: "can_view_reports",
|
||||
order: 8,
|
||||
order: 9,
|
||||
},
|
||||
{
|
||||
cardId: "osDistributionBar",
|
||||
requiredPermission: "can_view_reports",
|
||||
order: 9,
|
||||
order: 10,
|
||||
},
|
||||
{
|
||||
cardId: "osDistributionDoughnut",
|
||||
requiredPermission: "can_view_reports",
|
||||
order: 10,
|
||||
order: 11,
|
||||
},
|
||||
{
|
||||
cardId: "recentCollection",
|
||||
requiredPermission: "can_view_hosts",
|
||||
order: 11,
|
||||
order: 12,
|
||||
},
|
||||
{
|
||||
cardId: "updateStatus",
|
||||
requiredPermission: "can_view_reports",
|
||||
order: 12,
|
||||
order: 13,
|
||||
},
|
||||
{
|
||||
cardId: "packagePriority",
|
||||
requiredPermission: "can_view_packages",
|
||||
order: 13,
|
||||
order: 14,
|
||||
},
|
||||
{
|
||||
cardId: "packageTrends",
|
||||
requiredPermission: "can_view_packages",
|
||||
order: 14,
|
||||
order: 15,
|
||||
},
|
||||
{
|
||||
cardId: "recentUsers",
|
||||
requiredPermission: "can_view_users",
|
||||
order: 15,
|
||||
order: 16,
|
||||
},
|
||||
{
|
||||
cardId: "quickStats",
|
||||
requiredPermission: "can_view_dashboard",
|
||||
order: 16,
|
||||
order: 17,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -290,26 +295,33 @@ router.get("/defaults", authenticateToken, async (_req, res) => {
|
||||
enabled: true,
|
||||
order: 5,
|
||||
},
|
||||
{
|
||||
cardId: "hostsNeedingReboot",
|
||||
title: "Needs Reboots",
|
||||
icon: "RotateCcw",
|
||||
enabled: true,
|
||||
order: 6,
|
||||
},
|
||||
{
|
||||
cardId: "totalRepos",
|
||||
title: "Repositories",
|
||||
icon: "GitBranch",
|
||||
enabled: true,
|
||||
order: 6,
|
||||
order: 7,
|
||||
},
|
||||
{
|
||||
cardId: "totalUsers",
|
||||
title: "Users",
|
||||
icon: "Users",
|
||||
enabled: true,
|
||||
order: 7,
|
||||
order: 8,
|
||||
},
|
||||
{
|
||||
cardId: "osDistribution",
|
||||
title: "OS Distribution",
|
||||
icon: "BarChart3",
|
||||
enabled: true,
|
||||
order: 8,
|
||||
order: 9,
|
||||
},
|
||||
{
|
||||
cardId: "osDistributionBar",
|
||||
|
||||
@@ -41,6 +41,7 @@ router.get(
|
||||
erroredHosts,
|
||||
securityUpdates,
|
||||
offlineHosts,
|
||||
hostsNeedingReboot,
|
||||
totalHostGroups,
|
||||
totalUsers,
|
||||
totalRepos,
|
||||
@@ -106,6 +107,13 @@ router.get(
|
||||
},
|
||||
}),
|
||||
|
||||
// Hosts needing reboot
|
||||
prisma.hosts.count({
|
||||
where: {
|
||||
needs_reboot: true,
|
||||
},
|
||||
}),
|
||||
|
||||
// Total host groups count
|
||||
prisma.host_groups.count(),
|
||||
|
||||
@@ -174,6 +182,7 @@ router.get(
|
||||
erroredHosts,
|
||||
securityUpdates,
|
||||
offlineHosts,
|
||||
hostsNeedingReboot,
|
||||
totalHostGroups,
|
||||
totalUsers,
|
||||
totalRepos,
|
||||
@@ -217,6 +226,7 @@ router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => {
|
||||
auto_update: true,
|
||||
notes: true,
|
||||
api_id: true,
|
||||
needs_reboot: true,
|
||||
host_group_memberships: {
|
||||
include: {
|
||||
host_groups: {
|
||||
|
||||
@@ -59,6 +59,7 @@ router.get("/:id", authenticateToken, async (req, res) => {
|
||||
os_version: true,
|
||||
status: true,
|
||||
last_update: true,
|
||||
needs_reboot: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -259,6 +260,7 @@ router.get("/:id/hosts", authenticateToken, async (req, res) => {
|
||||
status: true,
|
||||
last_update: true,
|
||||
created_at: true,
|
||||
needs_reboot: true,
|
||||
},
|
||||
orderBy: {
|
||||
friendly_name: "asc",
|
||||
|
||||
@@ -533,6 +533,14 @@ router.post(
|
||||
.optional()
|
||||
.isString()
|
||||
.withMessage("Machine ID must be a string"),
|
||||
body("needsReboot")
|
||||
.optional()
|
||||
.isBoolean()
|
||||
.withMessage("Needs reboot must be a boolean"),
|
||||
body("rebootReason")
|
||||
.optional()
|
||||
.isString()
|
||||
.withMessage("Reboot reason must be a string"),
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
@@ -596,6 +604,10 @@ router.post(
|
||||
updateData.system_uptime = req.body.systemUptime;
|
||||
if (req.body.loadAverage) updateData.load_average = req.body.loadAverage;
|
||||
|
||||
// Reboot Status
|
||||
if (req.body.needsReboot !== undefined)
|
||||
updateData.needs_reboot = req.body.needsReboot;
|
||||
|
||||
// If this is the first update (status is 'pending'), change to 'active'
|
||||
if (host.status === "pending") {
|
||||
updateData.status = "active";
|
||||
|
||||
@@ -142,6 +142,7 @@ router.get("/", async (req, res) => {
|
||||
friendly_name: true,
|
||||
hostname: true,
|
||||
os_type: true,
|
||||
needs_reboot: true,
|
||||
},
|
||||
},
|
||||
current_version: true,
|
||||
@@ -236,6 +237,7 @@ router.get("/:packageId", async (req, res) => {
|
||||
os_type: true,
|
||||
os_version: true,
|
||||
last_update: true,
|
||||
needs_reboot: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -365,6 +367,7 @@ router.get("/:packageId/hosts", async (req, res) => {
|
||||
os_type: true,
|
||||
os_version: true,
|
||||
last_update: true,
|
||||
needs_reboot: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -386,6 +389,7 @@ router.get("/:packageId/hosts", async (req, res) => {
|
||||
needsUpdate: hp.needs_update,
|
||||
isSecurityUpdate: hp.is_security_update,
|
||||
lastChecked: hp.last_checked,
|
||||
needsReboot: hp.hosts.needs_reboot,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -119,6 +119,7 @@ router.get(
|
||||
os_version: true,
|
||||
status: true,
|
||||
last_update: true,
|
||||
needs_reboot: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -25,6 +25,9 @@ server {
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Prevent search engine indexing
|
||||
add_header X-Robots-Tag "noindex, nofollow, noarchive, nosnippet" always;
|
||||
|
||||
# Bull Board proxy - must come before the root location to avoid conflicts
|
||||
location /bullboard {
|
||||
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
|
||||
|
||||
@@ -4,6 +4,18 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Prevent search engine indexing -->
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
|
||||
<meta name="googlebot" content="noindex, nofollow, noarchive, nosnippet" />
|
||||
<meta name="bingbot" content="noindex, nofollow, noarchive, nosnippet" />
|
||||
<meta name="description" content="" />
|
||||
|
||||
<!-- Prevent caching -->
|
||||
<meta http-equiv="cache-control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="pragma" content="no-cache" />
|
||||
<meta http-equiv="expires" content="0" />
|
||||
|
||||
<title>PatchMon - Linux Patch Monitoring Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
|
||||
26
frontend/public/robots.txt
Normal file
26
frontend/public/robots.txt
Normal file
@@ -0,0 +1,26 @@
|
||||
# Disallow all search engine crawlers
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
|
||||
# Specifically target major search engines
|
||||
User-agent: Googlebot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Bingbot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Slurp
|
||||
Disallow: /
|
||||
|
||||
User-agent: DuckDuckBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Baiduspider
|
||||
Disallow: /
|
||||
|
||||
User-agent: YandexBot
|
||||
Disallow: /
|
||||
|
||||
# Prevent indexing of all content
|
||||
Disallow: /*
|
||||
|
||||
@@ -11,6 +11,12 @@ const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const BACKEND_URL = process.env.BACKEND_URL || "http://backend:3001";
|
||||
|
||||
// Add security headers to prevent search engine indexing
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive, nosnippet");
|
||||
next();
|
||||
});
|
||||
|
||||
// Enable CORS for API calls
|
||||
app.use(
|
||||
cors({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, CheckCircle, Save, Shield } from "lucide-react";
|
||||
import { AlertCircle, CheckCircle, Save, Shield, X } from "lucide-react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { permissionsAPI, settingsAPI } from "../../utils/api";
|
||||
|
||||
@@ -18,9 +18,60 @@ const AgentUpdatesTab = () => {
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Auto-hide toast after 3 seconds
|
||||
useEffect(() => {
|
||||
if (toast) {
|
||||
const timer = setTimeout(() => {
|
||||
setToast(null);
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
const showToast = (message, type = "success") => {
|
||||
setToast({ message, type });
|
||||
};
|
||||
|
||||
// Fallback clipboard copy function for HTTP and older browsers
|
||||
const copyToClipboard = async (text) => {
|
||||
// Try modern clipboard API first
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn("Clipboard API failed, using fallback:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for HTTP or unsupported browsers
|
||||
try {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.left = "-999999px";
|
||||
textArea.style.top = "-999999px";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
const successful = document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
if (successful) {
|
||||
return true;
|
||||
}
|
||||
throw new Error("execCommand failed");
|
||||
} catch (err) {
|
||||
console.error("Fallback copy failed:", err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch current settings
|
||||
const {
|
||||
data: settings,
|
||||
@@ -167,6 +218,53 @@ const AgentUpdatesTab = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Toast Notification */}
|
||||
{toast && (
|
||||
<div
|
||||
className={`fixed top-4 right-4 z-50 max-w-md rounded-lg shadow-lg border-2 p-4 flex items-start space-x-3 animate-in slide-in-from-top-5 ${
|
||||
toast.type === "success"
|
||||
? "bg-green-50 dark:bg-green-900/90 border-green-500 dark:border-green-600"
|
||||
: "bg-red-50 dark:bg-red-900/90 border-red-500 dark:border-red-600"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 rounded-full p-1 ${
|
||||
toast.type === "success"
|
||||
? "bg-green-100 dark:bg-green-800"
|
||||
: "bg-red-100 dark:bg-red-800"
|
||||
}`}
|
||||
>
|
||||
{toast.type === "success" ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
toast.type === "success"
|
||||
? "text-green-800 dark:text-green-100"
|
||||
: "text-red-800 dark:text-red-100"
|
||||
}`}
|
||||
>
|
||||
{toast.message}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setToast(null)}
|
||||
className={`flex-shrink-0 rounded-lg p-1 transition-colors ${
|
||||
toast.type === "success"
|
||||
? "hover:bg-green-100 dark:hover:bg-green-800 text-green-600 dark:text-green-400"
|
||||
: "hover:bg-red-100 dark:hover:bg-red-800 text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
@@ -458,19 +556,74 @@ const AgentUpdatesTab = () => {
|
||||
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||
<p className="mb-3">To completely remove PatchMon from a host:</p>
|
||||
|
||||
{/* Go Agent Uninstall */}
|
||||
{/* Agent Removal Script - Standard */}
|
||||
<div className="mb-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold text-red-700 dark:text-red-300 mb-1">
|
||||
Standard Removal (preserves backups):
|
||||
</div>
|
||||
<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">
|
||||
sudo patchmon-agent uninstall
|
||||
curl {formData.ignoreSslSelfSigned ? "-sk" : "-s"}{" "}
|
||||
{window.location.origin}/api/v1/hosts/remove | sudo sh
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
"sudo patchmon-agent uninstall",
|
||||
);
|
||||
onClick={async () => {
|
||||
try {
|
||||
const curlFlags = formData.ignoreSslSelfSigned
|
||||
? "-sk"
|
||||
: "-s";
|
||||
await copyToClipboard(
|
||||
`curl ${curlFlags} ${window.location.origin}/api/v1/hosts/remove | sudo sh`,
|
||||
);
|
||||
showToast(
|
||||
"Standard removal command copied!",
|
||||
"success",
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
showToast("Failed to copy to clipboard", "error");
|
||||
}
|
||||
}}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Removal Script - Complete */}
|
||||
<div className="mb-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold text-red-700 dark:text-red-300 mb-1">
|
||||
Complete Removal (includes backups):
|
||||
</div>
|
||||
<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 {formData.ignoreSslSelfSigned ? "-sk" : "-s"}{" "}
|
||||
{window.location.origin}/api/v1/hosts/remove | sudo
|
||||
REMOVE_BACKUPS=1 sh
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const curlFlags = formData.ignoreSslSelfSigned
|
||||
? "-sk"
|
||||
: "-s";
|
||||
await copyToClipboard(
|
||||
`curl ${curlFlags} ${window.location.origin}/api/v1/hosts/remove | sudo REMOVE_BACKUPS=1 sh`,
|
||||
);
|
||||
showToast(
|
||||
"Complete removal command copied!",
|
||||
"success",
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
showToast("Failed to copy to clipboard", "error");
|
||||
}
|
||||
}}
|
||||
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"
|
||||
>
|
||||
@@ -478,16 +631,15 @@ const AgentUpdatesTab = () => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-red-600 dark:text-red-400">
|
||||
Options: <code>--remove-config</code>,{" "}
|
||||
<code>--remove-logs</code>, <code>--remove-all</code>,{" "}
|
||||
<code>--force</code>
|
||||
This removes: binaries, systemd/OpenRC services,
|
||||
configuration files, logs, crontab entries, and backup files
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs">
|
||||
⚠️ This command will remove all PatchMon files, configuration,
|
||||
and crontab entries
|
||||
<p className="mt-2 text-xs text-red-700 dark:text-red-400">
|
||||
⚠️ Standard removal preserves backup files for safety. Use
|
||||
complete removal to delete everything.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
} from "chart.js";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Folder,
|
||||
GitBranch,
|
||||
Package,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Server,
|
||||
Settings,
|
||||
Shield,
|
||||
@@ -99,6 +101,20 @@ const Dashboard = () => {
|
||||
navigate("/repositories");
|
||||
};
|
||||
|
||||
const handleNeedsRebootClick = () => {
|
||||
// Navigate to hosts with reboot filter, clearing any other filters
|
||||
const newSearchParams = new URLSearchParams();
|
||||
newSearchParams.set("reboot", "true");
|
||||
navigate(`/hosts?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
const handleUpToDateClick = () => {
|
||||
// Navigate to hosts with upToDate filter, clearing any other filters
|
||||
const newSearchParams = new URLSearchParams();
|
||||
newSearchParams.set("filter", "upToDate");
|
||||
navigate(`/hosts?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
const _handleOSDistributionClick = () => {
|
||||
navigate("/hosts?showFilters=true", { replace: true });
|
||||
};
|
||||
@@ -308,9 +324,10 @@ const Dashboard = () => {
|
||||
[
|
||||
"totalHosts",
|
||||
"hostsNeedingUpdates",
|
||||
"upToDateHosts",
|
||||
"totalOutdatedPackages",
|
||||
"securityUpdates",
|
||||
"upToDateHosts",
|
||||
"hostsNeedingReboot",
|
||||
"totalHostGroups",
|
||||
"totalUsers",
|
||||
"totalRepos",
|
||||
@@ -341,7 +358,7 @@ const Dashboard = () => {
|
||||
const getGroupClassName = (cardType) => {
|
||||
switch (cardType) {
|
||||
case "stats":
|
||||
return "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4";
|
||||
return "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4";
|
||||
case "charts":
|
||||
return "grid grid-cols-1 lg:grid-cols-3 gap-6";
|
||||
case "widecharts":
|
||||
@@ -356,23 +373,33 @@ const Dashboard = () => {
|
||||
// Helper function to render a card by ID
|
||||
const renderCard = (cardId) => {
|
||||
switch (cardId) {
|
||||
case "upToDateHosts":
|
||||
case "hostsNeedingReboot":
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<button
|
||||
type="button"
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
||||
onClick={handleNeedsRebootClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleNeedsRebootClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<TrendingUp className="h-5 w-5 text-success-600 mr-2" />
|
||||
<RotateCcw className="h-5 w-5 text-orange-600 mr-2" />
|
||||
</div>
|
||||
<div className="w-0 flex-1">
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Up to date
|
||||
Needs Reboots
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{stats.cards.upToDateHosts}/{stats.cards.totalHosts}
|
||||
{stats.cards.hostsNeedingReboot}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
case "totalHosts":
|
||||
return (
|
||||
@@ -432,6 +459,35 @@ const Dashboard = () => {
|
||||
</button>
|
||||
);
|
||||
|
||||
case "upToDateHosts":
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
||||
onClick={handleUpToDateClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleUpToDateClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<CheckCircle className="h-5 w-5 text-success-600 mr-2" />
|
||||
</div>
|
||||
<div className="w-0 flex-1">
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Up to date
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{stats.cards.upToDateHosts}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
case "totalOutdatedPackages":
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Monitor,
|
||||
Package,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Server,
|
||||
Shield,
|
||||
Terminal,
|
||||
@@ -493,6 +494,12 @@ const HostDetail = () => {
|
||||
{getStatusIcon(isStale, host.stats.outdated_packages > 0)}
|
||||
{getStatusText(isStale, host.stats.outdated_packages > 0)}
|
||||
</div>
|
||||
{host.needs_reboot && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reboot Required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Info row with uptime and last updated */}
|
||||
<div className="flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
@@ -994,7 +1001,7 @@ const HostDetail = () => {
|
||||
<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">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{host.architecture && (
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
@@ -1006,20 +1013,32 @@ const HostDetail = () => {
|
||||
</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>
|
||||
{(host.kernel_version ||
|
||||
host.installed_kernel_version) && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{host.kernel_version && (
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1">
|
||||
Running Kernel
|
||||
</p>
|
||||
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm break-all">
|
||||
{host.kernel_version}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{host.installed_kernel_version && (
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1">
|
||||
Installed Kernel
|
||||
</p>
|
||||
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm break-all">
|
||||
{host.installed_kernel_version}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
GripVertical,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Server,
|
||||
Square,
|
||||
@@ -247,6 +248,7 @@ const Hosts = () => {
|
||||
const showFiltersParam = searchParams.get("showFilters");
|
||||
const osFilterParam = searchParams.get("osFilter");
|
||||
const groupParam = searchParams.get("group");
|
||||
const rebootParam = searchParams.get("reboot");
|
||||
|
||||
if (filter === "needsUpdates") {
|
||||
setShowFilters(true);
|
||||
@@ -331,10 +333,11 @@ const Hosts = () => {
|
||||
},
|
||||
{ id: "ws_status", label: "Connection", visible: true, order: 9 },
|
||||
{ id: "status", label: "Status", visible: true, order: 10 },
|
||||
{ id: "updates", label: "Updates", visible: true, order: 11 },
|
||||
{ id: "notes", label: "Notes", visible: false, order: 12 },
|
||||
{ id: "last_update", label: "Last Update", visible: true, order: 13 },
|
||||
{ id: "actions", label: "Actions", visible: true, order: 14 },
|
||||
{ id: "needs_reboot", label: "Reboot", visible: true, order: 11 },
|
||||
{ id: "updates", label: "Updates", visible: true, order: 12 },
|
||||
{ id: "notes", label: "Notes", visible: false, order: 13 },
|
||||
{ id: "last_update", label: "Last Update", visible: true, order: 14 },
|
||||
{ id: "actions", label: "Actions", visible: true, order: 15 },
|
||||
];
|
||||
|
||||
const saved = localStorage.getItem("hosts-column-config");
|
||||
@@ -356,8 +359,25 @@ const Hosts = () => {
|
||||
localStorage.removeItem("hosts-column-config");
|
||||
return defaultConfig;
|
||||
} else {
|
||||
// Ensure ws_status column is visible in saved config
|
||||
const updatedConfig = savedConfig.map((col) =>
|
||||
// Merge saved config with defaults to handle new columns
|
||||
// This preserves user's visibility preferences while adding new columns
|
||||
const mergedConfig = defaultConfig.map((defaultCol) => {
|
||||
const savedCol = savedConfig.find(
|
||||
(col) => col.id === defaultCol.id,
|
||||
);
|
||||
if (savedCol) {
|
||||
// Use saved visibility preference, but keep default order and label
|
||||
return {
|
||||
...defaultCol,
|
||||
visible: savedCol.visible,
|
||||
};
|
||||
}
|
||||
// New column not in saved config, use default
|
||||
return defaultCol;
|
||||
});
|
||||
|
||||
// Ensure ws_status column is visible
|
||||
const updatedConfig = mergedConfig.map((col) =>
|
||||
col.id === "ws_status" ? { ...col, visible: true } : col,
|
||||
);
|
||||
return updatedConfig;
|
||||
@@ -673,8 +693,9 @@ const Hosts = () => {
|
||||
osFilter === "all" ||
|
||||
host.os_type?.toLowerCase() === osFilter.toLowerCase();
|
||||
|
||||
// URL filter for hosts needing updates, inactive hosts, up-to-date hosts, stale hosts, or offline hosts
|
||||
// URL filter for hosts needing updates, inactive hosts, up-to-date hosts, stale hosts, offline hosts, or reboot required
|
||||
const filter = searchParams.get("filter");
|
||||
const rebootParam = searchParams.get("reboot");
|
||||
const matchesUrlFilter =
|
||||
(filter !== "needsUpdates" ||
|
||||
(host.updatesCount && host.updatesCount > 0)) &&
|
||||
@@ -682,7 +703,9 @@ const Hosts = () => {
|
||||
(host.effectiveStatus || host.status) === "inactive") &&
|
||||
(filter !== "upToDate" || (!host.isStale && host.updatesCount === 0)) &&
|
||||
(filter !== "stale" || host.isStale) &&
|
||||
(filter !== "offline" || wsStatusMap[host.api_id]?.connected !== true);
|
||||
(filter !== "offline" ||
|
||||
wsStatusMap[host.api_id]?.connected !== true) &&
|
||||
(!rebootParam || host.needs_reboot === true);
|
||||
|
||||
// Hide stale filter
|
||||
const matchesHideStale = !hideStale || !host.isStale;
|
||||
@@ -758,6 +781,11 @@ const Hosts = () => {
|
||||
aValue = a.updatesCount || 0;
|
||||
bValue = b.updatesCount || 0;
|
||||
break;
|
||||
case "needs_reboot":
|
||||
// Sort by boolean: false (0) comes before true (1)
|
||||
aValue = a.needs_reboot ? 1 : 0;
|
||||
bValue = b.needs_reboot ? 1 : 0;
|
||||
break;
|
||||
case "last_update":
|
||||
aValue = new Date(a.last_update);
|
||||
bValue = new Date(b.last_update);
|
||||
@@ -917,10 +945,11 @@ const Hosts = () => {
|
||||
},
|
||||
{ id: "ws_status", label: "Connection", visible: true, order: 9 },
|
||||
{ id: "status", label: "Status", visible: true, order: 10 },
|
||||
{ id: "updates", label: "Updates", visible: true, order: 11 },
|
||||
{ id: "notes", label: "Notes", visible: false, order: 12 },
|
||||
{ id: "last_update", label: "Last Update", visible: true, order: 13 },
|
||||
{ id: "actions", label: "Actions", visible: true, order: 14 },
|
||||
{ id: "needs_reboot", label: "Reboot", visible: true, order: 11 },
|
||||
{ id: "updates", label: "Updates", visible: true, order: 12 },
|
||||
{ id: "notes", label: "Notes", visible: false, order: 13 },
|
||||
{ id: "last_update", label: "Last Update", visible: true, order: 14 },
|
||||
{ id: "actions", label: "Actions", visible: true, order: 15 },
|
||||
];
|
||||
updateColumnConfig(defaultConfig);
|
||||
};
|
||||
@@ -1077,6 +1106,22 @@ const Hosts = () => {
|
||||
(host.effectiveStatus || host.status).slice(1)}
|
||||
</div>
|
||||
);
|
||||
case "needs_reboot":
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
{host.needs_reboot ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Required
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
No
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case "updates":
|
||||
return (
|
||||
<button
|
||||
@@ -1149,9 +1194,10 @@ const Hosts = () => {
|
||||
// Filter to show only up-to-date hosts
|
||||
setStatusFilter("active");
|
||||
setShowFilters(true);
|
||||
// Use the upToDate URL filter
|
||||
// Clear conflicting filters and set upToDate filter
|
||||
const newSearchParams = new URLSearchParams(window.location.search);
|
||||
newSearchParams.set("filter", "upToDate");
|
||||
newSearchParams.delete("reboot"); // Clear reboot filter when switching to upToDate
|
||||
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true });
|
||||
};
|
||||
|
||||
@@ -1159,9 +1205,10 @@ const Hosts = () => {
|
||||
// Filter to show hosts needing updates (regardless of status)
|
||||
setStatusFilter("all");
|
||||
setShowFilters(true);
|
||||
// We'll use the existing needsUpdates URL filter logic
|
||||
// Clear conflicting filters and set needsUpdates filter
|
||||
const newSearchParams = new URLSearchParams(window.location.search);
|
||||
newSearchParams.set("filter", "needsUpdates");
|
||||
newSearchParams.delete("reboot"); // Clear reboot filter when switching to needsUpdates
|
||||
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true });
|
||||
};
|
||||
|
||||
@@ -1169,9 +1216,10 @@ const Hosts = () => {
|
||||
// Filter to show offline hosts (not connected via WebSocket)
|
||||
setStatusFilter("all");
|
||||
setShowFilters(true);
|
||||
// Use a new URL filter for connection status
|
||||
// Clear conflicting filters and set offline filter
|
||||
const newSearchParams = new URLSearchParams(window.location.search);
|
||||
newSearchParams.set("filter", "offline");
|
||||
newSearchParams.delete("reboot"); // Clear reboot filter when switching to offline
|
||||
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true });
|
||||
};
|
||||
|
||||
@@ -1262,24 +1310,6 @@ const Hosts = () => {
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
|
||||
onClick={handleUpToDateClick}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 text-success-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Up to Date
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{hosts?.filter((h) => !h.isStale && h.updatesCount === 0)
|
||||
.length || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
|
||||
@@ -1297,6 +1327,28 @@ const Hosts = () => {
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
|
||||
onClick={() => {
|
||||
const newSearchParams = new URLSearchParams();
|
||||
newSearchParams.set("reboot", "true");
|
||||
// Clear filter parameter when setting reboot filter
|
||||
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true });
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<RotateCcw className="h-5 w-5 text-orange-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Needs Reboots
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{hosts?.filter((h) => h.needs_reboot === true).length || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
|
||||
@@ -1679,6 +1731,17 @@ const Hosts = () => {
|
||||
{column.label}
|
||||
{getSortIcon("updates")}
|
||||
</button>
|
||||
) : column.id === "needs_reboot" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleSort("needs_reboot")
|
||||
}
|
||||
className="flex items-center gap-2 hover:text-secondary-700"
|
||||
>
|
||||
{column.label}
|
||||
{getSortIcon("needs_reboot")}
|
||||
</button>
|
||||
) : column.id === "last_update" ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Download,
|
||||
Package,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Server,
|
||||
Shield,
|
||||
@@ -370,6 +371,9 @@ const PackageDetail = () => {
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Last Updated
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Reboot Required
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
@@ -420,6 +424,18 @@ const PackageDetail = () => {
|
||||
? formatRelativeTime(host.lastUpdate)
|
||||
: "Never"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{host.needsReboot ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Required
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
No
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Database,
|
||||
Globe,
|
||||
Lock,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Server,
|
||||
Shield,
|
||||
@@ -556,6 +557,9 @@ const RepositoryDetail = () => {
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Last Update
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Reboot Required
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
@@ -604,6 +608,18 @@ const RepositoryDetail = () => {
|
||||
? formatRelativeTime(hostRepo.hosts.last_update)
|
||||
: "Never"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{hostRepo.hosts.needs_reboot ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Required
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
No
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -53,6 +53,7 @@ const Settings = () => {
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
// Tab management
|
||||
const [activeTab, setActiveTab] = useState("server");
|
||||
@@ -60,6 +61,56 @@ const Settings = () => {
|
||||
// Get update notification state
|
||||
const { updateAvailable } = useUpdateNotification();
|
||||
|
||||
// Auto-hide toast after 3 seconds
|
||||
useEffect(() => {
|
||||
if (toast) {
|
||||
const timer = setTimeout(() => {
|
||||
setToast(null);
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
const showToast = (message, type = "success") => {
|
||||
setToast({ message, type });
|
||||
};
|
||||
|
||||
// Fallback clipboard copy function for HTTP and older browsers
|
||||
const copyToClipboard = async (text) => {
|
||||
// Try modern clipboard API first
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn("Clipboard API failed, using fallback:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for HTTP or unsupported browsers
|
||||
try {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.left = "-999999px";
|
||||
textArea.style.top = "-999999px";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
const successful = document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
if (successful) {
|
||||
return true;
|
||||
}
|
||||
throw new Error("execCommand failed");
|
||||
} catch (err) {
|
||||
console.error("Fallback copy failed:", err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Tab configuration
|
||||
const tabs = [
|
||||
{ id: "server", name: "Server Configuration", icon: Server },
|
||||
@@ -120,7 +171,7 @@ const Settings = () => {
|
||||
});
|
||||
|
||||
// Helper function to get curl flags based on settings
|
||||
const _getCurlFlags = () => {
|
||||
const getCurlFlags = () => {
|
||||
return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
|
||||
};
|
||||
|
||||
@@ -442,6 +493,53 @@ const Settings = () => {
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{/* Toast Notification */}
|
||||
{toast && (
|
||||
<div
|
||||
className={`fixed top-4 right-4 z-50 max-w-md rounded-lg shadow-lg border-2 p-4 flex items-start space-x-3 animate-in slide-in-from-top-5 ${
|
||||
toast.type === "success"
|
||||
? "bg-green-50 dark:bg-green-900/90 border-green-500 dark:border-green-600"
|
||||
: "bg-red-50 dark:bg-red-900/90 border-red-500 dark:border-red-600"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 rounded-full p-1 ${
|
||||
toast.type === "success"
|
||||
? "bg-green-100 dark:bg-green-800"
|
||||
: "bg-red-100 dark:bg-red-800"
|
||||
}`}
|
||||
>
|
||||
{toast.type === "success" ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
toast.type === "success"
|
||||
? "text-green-800 dark:text-green-100"
|
||||
: "text-red-800 dark:text-red-100"
|
||||
}`}
|
||||
>
|
||||
{toast.message}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setToast(null)}
|
||||
className={`flex-shrink-0 rounded-lg p-1 transition-colors ${
|
||||
toast.type === "success"
|
||||
? "hover:bg-green-100 dark:hover:bg-green-800 text-green-600 dark:text-green-400"
|
||||
: "hover:bg-red-100 dark:hover:bg-red-800 text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8">
|
||||
<p className="text-secondary-600 dark:text-secondary-300">
|
||||
Configure your PatchMon server settings. These settings will be used
|
||||
@@ -1159,19 +1257,74 @@ const Settings = () => {
|
||||
To completely remove PatchMon from a host:
|
||||
</p>
|
||||
|
||||
{/* Go Agent Uninstall */}
|
||||
{/* Agent Removal Script - Standard */}
|
||||
<div className="mb-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold text-red-700 dark:text-red-300 mb-1">
|
||||
Standard Removal (preserves backups):
|
||||
</div>
|
||||
<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">
|
||||
sudo patchmon-agent uninstall
|
||||
curl {getCurlFlags()} {window.location.origin}
|
||||
/api/v1/hosts/remove | sudo sh
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
"sudo patchmon-agent uninstall",
|
||||
);
|
||||
onClick={async () => {
|
||||
try {
|
||||
await copyToClipboard(
|
||||
`curl ${getCurlFlags()} ${window.location.origin}/api/v1/hosts/remove | sudo sh`,
|
||||
);
|
||||
showToast(
|
||||
"Standard removal command copied!",
|
||||
"success",
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
showToast(
|
||||
"Failed to copy to clipboard",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Removal Script - Complete */}
|
||||
<div className="mb-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold text-red-700 dark:text-red-300 mb-1">
|
||||
Complete Removal (includes backups):
|
||||
</div>
|
||||
<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 REMOVE_BACKUPS=1
|
||||
sh
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await copyToClipboard(
|
||||
`curl ${getCurlFlags()} ${window.location.origin}/api/v1/hosts/remove | sudo REMOVE_BACKUPS=1 sh`,
|
||||
);
|
||||
showToast(
|
||||
"Complete removal command copied!",
|
||||
"success",
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
showToast(
|
||||
"Failed to copy to clipboard",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}}
|
||||
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"
|
||||
>
|
||||
@@ -1179,16 +1332,16 @@ const Settings = () => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-red-600 dark:text-red-400">
|
||||
Options: <code>--remove-config</code>,{" "}
|
||||
<code>--remove-logs</code>,{" "}
|
||||
<code>--remove-all</code>, <code>--force</code>
|
||||
This removes: binaries, systemd/OpenRC services,
|
||||
configuration files, logs, crontab entries, and
|
||||
backup files
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs">
|
||||
⚠️ This command will remove all PatchMon files,
|
||||
configuration, and crontab entries
|
||||
<p className="mt-2 text-xs text-red-700 dark:text-red-400">
|
||||
⚠️ Standard removal preserves backup files for
|
||||
safety. Use complete removal to delete everything.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user