added reboot required flag

added kernel running/installed version
Added dasboard card and filteres for reboot
fixed security qty updated on dnf
This commit is contained in:
Muhammad Ibrahim
2025-11-16 22:50:41 +00:00
parent 8df6ca2342
commit 539bbb7fbc
24 changed files with 918 additions and 200 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -2,7 +2,9 @@
# PatchMon Agent Removal Script # PatchMon Agent Removal Script
# POSIX-compliant shell script (works with dash, ash, bash, etc.) # 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 # This script completely removes PatchMon from the system
set -e set -e
@@ -12,12 +14,30 @@ set -e
# future (left for consistency with install script). # future (left for consistency with install script).
CURL_FLAGS="" CURL_FLAGS=""
# Colors for output # Detect if running in silent mode (only with explicit SILENT env var)
RED='\033[0;31m' SILENT_MODE=0
GREEN='\033[0;32m' if [ -n "$SILENT" ]; then
YELLOW='\033[1;33m' SILENT_MODE=1
BLUE='\033[0;34m' fi
NC='\033[0m' # No Color
# 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 # Functions
error() { error() {
@@ -26,15 +46,21 @@ error() {
} }
info() { info() {
printf "%b\n" "${BLUE} $1${NC}" if [ "$SILENT_MODE" -eq 0 ]; then
printf "%b\n" "${BLUE} $1${NC}"
fi
} }
success() { success() {
printf "%b\n" "${GREEN}$1${NC}" if [ "$SILENT_MODE" -eq 0 ]; then
printf "%b\n" "${GREEN}$1${NC}"
fi
} }
warning() { 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 # Check if running as root
@@ -43,7 +69,7 @@ if [ "$(id -u)" -ne 0 ]; then
fi fi
info "🗑️ Starting PatchMon Agent Removal..." info "🗑️ Starting PatchMon Agent Removal..."
echo "" [ "$SILENT_MODE" -eq 0 ] && echo ""
# Step 1: Stop systemd/OpenRC service if it exists # Step 1: Stop systemd/OpenRC service if it exists
info "🛑 Stopping PatchMon service..." info "🛑 Stopping PatchMon service..."
@@ -51,24 +77,75 @@ SERVICE_STOPPED=0
# Check for systemd service # Check for systemd service
if command -v systemctl >/dev/null 2>&1; then 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 if systemctl is-active --quiet patchmon-agent.service 2>/dev/null; then
warning "Stopping systemd service..." SERVICE_STATUS=$(systemctl is-active patchmon-agent.service 2>/dev/null || echo "unknown")
systemctl stop patchmon-agent.service || true warning "Service is active (status: $SERVICE_STATUS). Stopping it now..."
SERVICE_STOPPED=1 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 fi
# Check if service is enabled
if systemctl is-enabled --quiet patchmon-agent.service 2>/dev/null; then if systemctl is-enabled --quiet patchmon-agent.service 2>/dev/null; then
warning "Disabling systemd service..." ENABLED_STATUS=$(systemctl is-enabled patchmon-agent.service 2>/dev/null || echo "unknown")
systemctl disable patchmon-agent.service || true 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 fi
# Check for service file
if [ -f "/etc/systemd/system/patchmon-agent.service" ]; then if [ -f "/etc/systemd/system/patchmon-agent.service" ]; then
warning "Removing systemd service file..." warning "Found service file: /etc/systemd/system/patchmon-agent.service"
rm -f /etc/systemd/system/patchmon-agent.service info "Removing service file..."
systemctl daemon-reload || true if rm -f /etc/systemd/system/patchmon-agent.service 2>/dev/null; then
success "Systemd service removed" 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 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 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 fi
# Check for OpenRC service (Alpine Linux) # Check for OpenRC service (Alpine Linux)
@@ -93,11 +170,47 @@ if command -v rc-service >/dev/null 2>&1; then
fi fi
# Stop any remaining running processes (legacy or manual starts) # Stop any remaining running processes (legacy or manual starts)
info "🔍 Checking for running PatchMon processes..."
if pgrep -f "patchmon-agent" >/dev/null; then if pgrep -f "patchmon-agent" >/dev/null; then
warning "Found running PatchMon processes, stopping them..." PROCESS_COUNT=$(pgrep -f "patchmon-agent" | wc -l | tr -d ' ')
pkill -f "patchmon-agent" || true 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 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 SERVICE_STOPPED=1
else
info "No running PatchMon processes found"
fi fi
if [ "$SERVICE_STOPPED" -eq 1 ]; then if [ "$SERVICE_STOPPED" -eq 1 ]; then
@@ -159,11 +272,13 @@ info "📁 Removing configuration files..."
if [ -d "/etc/patchmon" ]; then if [ -d "/etc/patchmon" ]; then
warning "Removing configuration directory: /etc/patchmon" warning "Removing configuration directory: /etc/patchmon"
# Show what's being removed # Show what's being removed (only in verbose mode)
info "📋 Files in /etc/patchmon:" if [ "$SILENT_MODE" -eq 0 ]; then
ls -la /etc/patchmon/ 2>/dev/null | grep -v "^total" | while read -r line; do info "📋 Files in /etc/patchmon:"
echo " $line" ls -la /etc/patchmon/ 2>/dev/null | grep -v "^total" | while read -r line; do
done echo " $line"
done
fi
# Remove the directory # Remove the directory
rm -rf /etc/patchmon rm -rf /etc/patchmon
@@ -182,83 +297,105 @@ else
info "Log file not found" info "Log file not found"
fi fi
# Step 6: Clean up backup files (optional) # Step 6: Clean up backup files
info "🧹 Cleaning up backup files..." info "🧹 Checking backup files..."
BACKUP_COUNT=0 BACKUP_COUNT=0
BACKUP_REMOVED=0
# Count credential backups if [ "$REMOVE_BACKUPS" -eq 1 ]; then
CRED_BACKUPS=$(ls /etc/patchmon/credentials.backup.* 2>/dev/null | wc -l || echo "0") info "Removing backup files (REMOVE_BACKUPS=1)..."
if [ "$CRED_BACKUPS" -gt 0 ]; then
BACKUP_COUNT=$((BACKUP_COUNT + CRED_BACKUPS))
fi
# Count agent backups # Remove credential backups (already removed with /etc/patchmon directory, but check anyway)
AGENT_BACKUPS=$(ls /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | wc -l || echo "0") if ls /etc/patchmon/credentials.backup.* >/dev/null 2>&1; then
if [ "$AGENT_BACKUPS" -gt 0 ]; then CRED_BACKUPS=$(ls /etc/patchmon/credentials.backup.* 2>/dev/null | wc -l | tr -d ' ')
BACKUP_COUNT=$((BACKUP_COUNT + AGENT_BACKUPS)) warning "Removing $CRED_BACKUPS credential backup file(s)..."
fi rm -f /etc/patchmon/credentials.backup.*
BACKUP_COUNT=$((BACKUP_COUNT + CRED_BACKUPS))
# Count log backups BACKUP_REMOVED=1
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
fi fi
# Show agent backups # Remove config backups (already removed with /etc/patchmon directory, but check anyway)
if [ "$AGENT_BACKUPS" -gt 0 ]; then if ls /etc/patchmon/*.backup.* >/dev/null 2>&1; then
echo " Agent script backups:" CONFIG_BACKUPS=$(ls /etc/patchmon/*.backup.* 2>/dev/null | wc -l | tr -d ' ')
ls /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | while read -r file; do warning "Removing $CONFIG_BACKUPS config backup file(s)..."
echo "$file" rm -f /etc/patchmon/*.backup.*
done BACKUP_COUNT=$((BACKUP_COUNT + CONFIG_BACKUPS))
BACKUP_REMOVED=1
fi fi
# Show log backups # Remove Go agent backups
if [ "$LOG_BACKUPS" -gt 0 ]; then if ls /usr/local/bin/patchmon-agent.backup.* >/dev/null 2>&1; then
echo " Log file backups:" GO_AGENT_BACKUPS=$(ls /usr/local/bin/patchmon-agent.backup.* 2>/dev/null | wc -l | tr -d ' ')
ls /var/log/patchmon-agent.log.old.* 2>/dev/null | while read -r file; do warning "Removing $GO_AGENT_BACKUPS Go agent backup file(s)..."
echo "$file" rm -f /usr/local/bin/patchmon-agent.backup.*
done BACKUP_COUNT=$((BACKUP_COUNT + GO_AGENT_BACKUPS))
BACKUP_REMOVED=1
fi fi
echo "" # Remove legacy shell agent backups
printf "%b\n" "${BLUE}💡 Note: Backup files are preserved for safety${NC}" if ls /usr/local/bin/patchmon-agent.sh.backup.* >/dev/null 2>&1; then
printf "%b\n" "${BLUE}💡 You can remove them manually if not needed${NC}" 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 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 fi
# Step 7: Remove dependencies (optional) # Step 7: Final verification
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
info "🔍 Verifying removal..." info "🔍 Verifying removal..."
REMAINING_FILES=0 REMAINING_FILES=0
@@ -294,21 +431,30 @@ if [ "$REMAINING_FILES" -eq 0 ]; then
success "✅ PatchMon has been completely removed from the system!" success "✅ PatchMon has been completely removed from the system!"
else else
warning "⚠️ Some PatchMon files may still remain ($REMAINING_FILES items)" 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 fi
echo "" if [ "$SILENT_MODE" -eq 0 ]; then
printf "%b\n" "${GREEN}📋 Removal Summary:${NC}" echo ""
echo " • Agent binaries: Removed" printf "%b\n" "${GREEN}📋 Removal Summary:${NC}"
echo " • System services: Removed (systemd/OpenRC)" echo " • Agent binaries: Removed"
echo " • Configuration files: Removed" echo " • System services: Removed (systemd/OpenRC)"
echo " • Log files: Removed" echo " • Configuration files: Removed"
echo " • Crontab entries: Removed" echo " • Log files: Removed"
echo " • Running processes: Stopped" echo " • Crontab entries: Removed"
echo " • Backup files: Preserved (if any)" echo " • Running processes: Stopped"
echo "" if [ "$REMOVE_BACKUPS" -eq 1 ]; then
printf "%b\n" "${BLUE}🔧 Manual cleanup (if needed):${NC}" echo " • Backup files: Removed"
echo " • Remove backup files: rm /etc/patchmon/credentials.backup.* /usr/local/bin/patchmon-agent.sh.backup.* /var/log/patchmon-agent.log.old.*" else
echo " • Remove dependencies: apt remove jq curl (if not needed by other apps)" echo "Backup files: Preserved (${BACKUP_COUNT} files)"
echo "" 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!" success "🎉 PatchMon removal completed!"

View File

@@ -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");

View File

@@ -110,6 +110,7 @@ model hosts {
swap_size Int? swap_size Int?
system_uptime String? system_uptime String?
notes String? notes String?
needs_reboot Boolean? @default(false)
host_packages host_packages[] host_packages host_packages[]
host_repositories host_repositories[] host_repositories host_repositories[]
host_group_memberships host_group_memberships[] host_group_memberships host_group_memberships[]
@@ -121,6 +122,7 @@ model hosts {
@@index([machine_id]) @@index([machine_id])
@@index([friendly_name]) @@index([friendly_name])
@@index([hostname]) @@index([hostname])
@@index([needs_reboot])
} }
model packages { model packages {

View File

@@ -92,58 +92,63 @@ async function createDefaultDashboardPreferences(userId, userRole = "user") {
requiredPermission: "can_view_hosts", requiredPermission: "can_view_hosts",
order: 5, order: 5,
}, },
{
cardId: "hostsNeedingReboot",
requiredPermission: "can_view_hosts",
order: 6,
},
// Repository-related cards // Repository-related cards
{ cardId: "totalRepos", requiredPermission: "can_view_hosts", order: 6 }, { cardId: "totalRepos", requiredPermission: "can_view_hosts", order: 7 },
// User management cards (admin only) // User management cards (admin only)
{ cardId: "totalUsers", requiredPermission: "can_view_users", order: 7 }, { cardId: "totalUsers", requiredPermission: "can_view_users", order: 8 },
// System/Report cards // System/Report cards
{ {
cardId: "osDistribution", cardId: "osDistribution",
requiredPermission: "can_view_reports", requiredPermission: "can_view_reports",
order: 8, order: 9,
}, },
{ {
cardId: "osDistributionBar", cardId: "osDistributionBar",
requiredPermission: "can_view_reports", requiredPermission: "can_view_reports",
order: 9, order: 10,
}, },
{ {
cardId: "osDistributionDoughnut", cardId: "osDistributionDoughnut",
requiredPermission: "can_view_reports", requiredPermission: "can_view_reports",
order: 10, order: 11,
}, },
{ {
cardId: "recentCollection", cardId: "recentCollection",
requiredPermission: "can_view_hosts", requiredPermission: "can_view_hosts",
order: 11, order: 12,
}, },
{ {
cardId: "updateStatus", cardId: "updateStatus",
requiredPermission: "can_view_reports", requiredPermission: "can_view_reports",
order: 12, order: 13,
}, },
{ {
cardId: "packagePriority", cardId: "packagePriority",
requiredPermission: "can_view_packages", requiredPermission: "can_view_packages",
order: 13, order: 14,
}, },
{ {
cardId: "packageTrends", cardId: "packageTrends",
requiredPermission: "can_view_packages", requiredPermission: "can_view_packages",
order: 14, order: 15,
}, },
{ {
cardId: "recentUsers", cardId: "recentUsers",
requiredPermission: "can_view_users", requiredPermission: "can_view_users",
order: 15, order: 16,
}, },
{ {
cardId: "quickStats", cardId: "quickStats",
requiredPermission: "can_view_dashboard", requiredPermission: "can_view_dashboard",
order: 16, order: 17,
}, },
]; ];
@@ -290,26 +295,33 @@ router.get("/defaults", authenticateToken, async (_req, res) => {
enabled: true, enabled: true,
order: 5, order: 5,
}, },
{
cardId: "hostsNeedingReboot",
title: "Needs Reboots",
icon: "RotateCcw",
enabled: true,
order: 6,
},
{ {
cardId: "totalRepos", cardId: "totalRepos",
title: "Repositories", title: "Repositories",
icon: "GitBranch", icon: "GitBranch",
enabled: true, enabled: true,
order: 6, order: 7,
}, },
{ {
cardId: "totalUsers", cardId: "totalUsers",
title: "Users", title: "Users",
icon: "Users", icon: "Users",
enabled: true, enabled: true,
order: 7, order: 8,
}, },
{ {
cardId: "osDistribution", cardId: "osDistribution",
title: "OS Distribution", title: "OS Distribution",
icon: "BarChart3", icon: "BarChart3",
enabled: true, enabled: true,
order: 8, order: 9,
}, },
{ {
cardId: "osDistributionBar", cardId: "osDistributionBar",

View File

@@ -41,6 +41,7 @@ router.get(
erroredHosts, erroredHosts,
securityUpdates, securityUpdates,
offlineHosts, offlineHosts,
hostsNeedingReboot,
totalHostGroups, totalHostGroups,
totalUsers, totalUsers,
totalRepos, totalRepos,
@@ -106,6 +107,13 @@ router.get(
}, },
}), }),
// Hosts needing reboot
prisma.hosts.count({
where: {
needs_reboot: true,
},
}),
// Total host groups count // Total host groups count
prisma.host_groups.count(), prisma.host_groups.count(),
@@ -174,6 +182,7 @@ router.get(
erroredHosts, erroredHosts,
securityUpdates, securityUpdates,
offlineHosts, offlineHosts,
hostsNeedingReboot,
totalHostGroups, totalHostGroups,
totalUsers, totalUsers,
totalRepos, totalRepos,
@@ -217,6 +226,7 @@ router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => {
auto_update: true, auto_update: true,
notes: true, notes: true,
api_id: true, api_id: true,
needs_reboot: true,
host_group_memberships: { host_group_memberships: {
include: { include: {
host_groups: { host_groups: {

View File

@@ -59,6 +59,7 @@ router.get("/:id", authenticateToken, async (req, res) => {
os_version: true, os_version: true,
status: true, status: true,
last_update: true, last_update: true,
needs_reboot: true,
}, },
}, },
}, },
@@ -259,6 +260,7 @@ router.get("/:id/hosts", authenticateToken, async (req, res) => {
status: true, status: true,
last_update: true, last_update: true,
created_at: true, created_at: true,
needs_reboot: true,
}, },
orderBy: { orderBy: {
friendly_name: "asc", friendly_name: "asc",

View File

@@ -533,6 +533,14 @@ router.post(
.optional() .optional()
.isString() .isString()
.withMessage("Machine ID must be a string"), .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) => { async (req, res) => {
try { try {
@@ -596,6 +604,10 @@ router.post(
updateData.system_uptime = req.body.systemUptime; updateData.system_uptime = req.body.systemUptime;
if (req.body.loadAverage) updateData.load_average = req.body.loadAverage; 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 this is the first update (status is 'pending'), change to 'active'
if (host.status === "pending") { if (host.status === "pending") {
updateData.status = "active"; updateData.status = "active";

View File

@@ -142,6 +142,7 @@ router.get("/", async (req, res) => {
friendly_name: true, friendly_name: true,
hostname: true, hostname: true,
os_type: true, os_type: true,
needs_reboot: true,
}, },
}, },
current_version: true, current_version: true,
@@ -236,6 +237,7 @@ router.get("/:packageId", async (req, res) => {
os_type: true, os_type: true,
os_version: true, os_version: true,
last_update: true, last_update: true,
needs_reboot: true,
}, },
}, },
}, },
@@ -365,6 +367,7 @@ router.get("/:packageId/hosts", async (req, res) => {
os_type: true, os_type: true,
os_version: true, os_version: true,
last_update: true, last_update: true,
needs_reboot: true,
}, },
}, },
}, },
@@ -386,6 +389,7 @@ router.get("/:packageId/hosts", async (req, res) => {
needsUpdate: hp.needs_update, needsUpdate: hp.needs_update,
isSecurityUpdate: hp.is_security_update, isSecurityUpdate: hp.is_security_update,
lastChecked: hp.last_checked, lastChecked: hp.last_checked,
needsReboot: hp.hosts.needs_reboot,
})); }));
res.json({ res.json({

View File

@@ -119,6 +119,7 @@ router.get(
os_version: true, os_version: true,
status: true, status: true,
last_update: true, last_update: true,
needs_reboot: true,
}, },
}, },
}, },

View File

@@ -25,6 +25,9 @@ server {
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" 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 # Bull Board proxy - must come before the root location to avoid conflicts
location /bullboard { location /bullboard {
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT}; proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};

View File

@@ -4,6 +4,18 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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> <title>PatchMon - Linux Patch Monitoring Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

View 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: /*

View File

@@ -11,6 +11,12 @@ const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const BACKEND_URL = process.env.BACKEND_URL || "http://backend:3001"; 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 // Enable CORS for API calls
app.use( app.use(
cors({ cors({

View File

@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { useEffect, useId, useState } from "react";
import { permissionsAPI, settingsAPI } from "../../utils/api"; import { permissionsAPI, settingsAPI } from "../../utils/api";
@@ -18,9 +18,60 @@ const AgentUpdatesTab = () => {
}); });
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [toast, setToast] = useState(null);
const queryClient = useQueryClient(); 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 // Fetch current settings
const { const {
data: settings, data: settings,
@@ -167,6 +218,53 @@ const AgentUpdatesTab = () => {
return ( return (
<div className="space-y-6"> <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 && ( {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="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex"> <div className="flex">
@@ -458,19 +556,74 @@ const AgentUpdatesTab = () => {
<div className="mt-2 text-sm text-red-700 dark:text-red-300"> <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> <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="mb-3">
<div className="space-y-2"> <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="flex items-center gap-2">
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1"> <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> </div>
<button <button
type="button" type="button"
onClick={() => { onClick={async () => {
navigator.clipboard.writeText( try {
"sudo patchmon-agent uninstall", 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" 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> </button>
</div> </div>
<div className="text-xs text-red-600 dark:text-red-400"> <div className="text-xs text-red-600 dark:text-red-400">
Options: <code>--remove-config</code>,{" "} This removes: binaries, systemd/OpenRC services,
<code>--remove-logs</code>, <code>--remove-all</code>,{" "} configuration files, logs, crontab entries, and backup files
<code>--force</code>
</div> </div>
</div> </div>
</div> </div>
<p className="mt-2 text-xs"> <p className="mt-2 text-xs text-red-700 dark:text-red-400">
This command will remove all PatchMon files, configuration, Standard removal preserves backup files for safety. Use
and crontab entries complete removal to delete everything.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -13,10 +13,12 @@ import {
} from "chart.js"; } from "chart.js";
import { import {
AlertTriangle, AlertTriangle,
CheckCircle,
Folder, Folder,
GitBranch, GitBranch,
Package, Package,
RefreshCw, RefreshCw,
RotateCcw,
Server, Server,
Settings, Settings,
Shield, Shield,
@@ -99,6 +101,20 @@ const Dashboard = () => {
navigate("/repositories"); 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 = () => { const _handleOSDistributionClick = () => {
navigate("/hosts?showFilters=true", { replace: true }); navigate("/hosts?showFilters=true", { replace: true });
}; };
@@ -308,9 +324,10 @@ const Dashboard = () => {
[ [
"totalHosts", "totalHosts",
"hostsNeedingUpdates", "hostsNeedingUpdates",
"upToDateHosts",
"totalOutdatedPackages", "totalOutdatedPackages",
"securityUpdates", "securityUpdates",
"upToDateHosts", "hostsNeedingReboot",
"totalHostGroups", "totalHostGroups",
"totalUsers", "totalUsers",
"totalRepos", "totalRepos",
@@ -341,7 +358,7 @@ const Dashboard = () => {
const getGroupClassName = (cardType) => { const getGroupClassName = (cardType) => {
switch (cardType) { switch (cardType) {
case "stats": 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": case "charts":
return "grid grid-cols-1 lg:grid-cols-3 gap-6"; return "grid grid-cols-1 lg:grid-cols-3 gap-6";
case "widecharts": case "widecharts":
@@ -356,23 +373,33 @@ const Dashboard = () => {
// Helper function to render a card by ID // Helper function to render a card by ID
const renderCard = (cardId) => { const renderCard = (cardId) => {
switch (cardId) { switch (cardId) {
case "upToDateHosts": case "hostsNeedingReboot":
return ( 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 items-center">
<div className="flex-shrink-0"> <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>
<div className="w-0 flex-1"> <div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white"> <p className="text-sm text-secondary-500 dark:text-white">
Up to date Needs Reboots
</p> </p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white"> <p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.upToDateHosts}/{stats.cards.totalHosts} {stats.cards.hostsNeedingReboot}
</p> </p>
</div> </div>
</div> </div>
</div> </button>
); );
case "totalHosts": case "totalHosts":
return ( return (
@@ -432,6 +459,35 @@ const Dashboard = () => {
</button> </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": case "totalOutdatedPackages":
return ( return (
<button <button

View File

@@ -21,6 +21,7 @@ import {
Monitor, Monitor,
Package, Package,
RefreshCw, RefreshCw,
RotateCcw,
Server, Server,
Shield, Shield,
Terminal, Terminal,
@@ -493,6 +494,12 @@ const HostDetail = () => {
{getStatusIcon(isStale, host.stats.outdated_packages > 0)} {getStatusIcon(isStale, host.stats.outdated_packages > 0)}
{getStatusText(isStale, host.stats.outdated_packages > 0)} {getStatusText(isStale, host.stats.outdated_packages > 0)}
</div> </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> </div>
{/* Info row with uptime and last updated */} {/* Info row with uptime and last updated */}
<div className="flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-400"> <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" /> <Terminal className="h-4 w-4 text-primary-600 dark:text-primary-400" />
System Information System Information
</h4> </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 && ( {host.architecture && (
<div> <div>
<p className="text-xs text-secondary-500 dark:text-secondary-300"> <p className="text-xs text-secondary-500 dark:text-secondary-300">
@@ -1006,20 +1013,32 @@ const HostDetail = () => {
</div> </div>
)} )}
{host.kernel_version && ( {(host.kernel_version ||
<div> host.installed_kernel_version) && (
<p className="text-xs text-secondary-500 dark:text-secondary-300"> <div className="flex flex-col gap-2">
Kernel Version {host.kernel_version && (
</p> <div>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm"> <p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1">
{host.kernel_version} Running Kernel
</p> </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> </div>
)} )}
{/* Empty div to push SELinux status to the right */}
<div></div>
{host.selinux_status && ( {host.selinux_status && (
<div> <div>
<p className="text-xs text-secondary-500 dark:text-secondary-300"> <p className="text-xs text-secondary-500 dark:text-secondary-300">

View File

@@ -16,6 +16,7 @@ import {
GripVertical, GripVertical,
Plus, Plus,
RefreshCw, RefreshCw,
RotateCcw,
Search, Search,
Server, Server,
Square, Square,
@@ -247,6 +248,7 @@ const Hosts = () => {
const showFiltersParam = searchParams.get("showFilters"); const showFiltersParam = searchParams.get("showFilters");
const osFilterParam = searchParams.get("osFilter"); const osFilterParam = searchParams.get("osFilter");
const groupParam = searchParams.get("group"); const groupParam = searchParams.get("group");
const rebootParam = searchParams.get("reboot");
if (filter === "needsUpdates") { if (filter === "needsUpdates") {
setShowFilters(true); setShowFilters(true);
@@ -331,10 +333,11 @@ const Hosts = () => {
}, },
{ id: "ws_status", label: "Connection", visible: true, order: 9 }, { id: "ws_status", label: "Connection", visible: true, order: 9 },
{ id: "status", label: "Status", visible: true, order: 10 }, { id: "status", label: "Status", visible: true, order: 10 },
{ id: "updates", label: "Updates", visible: true, order: 11 }, { id: "needs_reboot", label: "Reboot", visible: true, order: 11 },
{ id: "notes", label: "Notes", visible: false, order: 12 }, { id: "updates", label: "Updates", visible: true, order: 12 },
{ id: "last_update", label: "Last Update", visible: true, order: 13 }, { id: "notes", label: "Notes", visible: false, order: 13 },
{ id: "actions", label: "Actions", visible: true, order: 14 }, { 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"); const saved = localStorage.getItem("hosts-column-config");
@@ -356,8 +359,25 @@ const Hosts = () => {
localStorage.removeItem("hosts-column-config"); localStorage.removeItem("hosts-column-config");
return defaultConfig; return defaultConfig;
} else { } else {
// Ensure ws_status column is visible in saved config // Merge saved config with defaults to handle new columns
const updatedConfig = savedConfig.map((col) => // 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, col.id === "ws_status" ? { ...col, visible: true } : col,
); );
return updatedConfig; return updatedConfig;
@@ -673,8 +693,9 @@ const Hosts = () => {
osFilter === "all" || osFilter === "all" ||
host.os_type?.toLowerCase() === osFilter.toLowerCase(); 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 filter = searchParams.get("filter");
const rebootParam = searchParams.get("reboot");
const matchesUrlFilter = const matchesUrlFilter =
(filter !== "needsUpdates" || (filter !== "needsUpdates" ||
(host.updatesCount && host.updatesCount > 0)) && (host.updatesCount && host.updatesCount > 0)) &&
@@ -682,7 +703,9 @@ const Hosts = () => {
(host.effectiveStatus || host.status) === "inactive") && (host.effectiveStatus || host.status) === "inactive") &&
(filter !== "upToDate" || (!host.isStale && host.updatesCount === 0)) && (filter !== "upToDate" || (!host.isStale && host.updatesCount === 0)) &&
(filter !== "stale" || host.isStale) && (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 // Hide stale filter
const matchesHideStale = !hideStale || !host.isStale; const matchesHideStale = !hideStale || !host.isStale;
@@ -758,6 +781,11 @@ const Hosts = () => {
aValue = a.updatesCount || 0; aValue = a.updatesCount || 0;
bValue = b.updatesCount || 0; bValue = b.updatesCount || 0;
break; 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": case "last_update":
aValue = new Date(a.last_update); aValue = new Date(a.last_update);
bValue = new Date(b.last_update); bValue = new Date(b.last_update);
@@ -917,10 +945,11 @@ const Hosts = () => {
}, },
{ id: "ws_status", label: "Connection", visible: true, order: 9 }, { id: "ws_status", label: "Connection", visible: true, order: 9 },
{ id: "status", label: "Status", visible: true, order: 10 }, { id: "status", label: "Status", visible: true, order: 10 },
{ id: "updates", label: "Updates", visible: true, order: 11 }, { id: "needs_reboot", label: "Reboot", visible: true, order: 11 },
{ id: "notes", label: "Notes", visible: false, order: 12 }, { id: "updates", label: "Updates", visible: true, order: 12 },
{ id: "last_update", label: "Last Update", visible: true, order: 13 }, { id: "notes", label: "Notes", visible: false, order: 13 },
{ id: "actions", label: "Actions", visible: true, order: 14 }, { id: "last_update", label: "Last Update", visible: true, order: 14 },
{ id: "actions", label: "Actions", visible: true, order: 15 },
]; ];
updateColumnConfig(defaultConfig); updateColumnConfig(defaultConfig);
}; };
@@ -1077,6 +1106,22 @@ const Hosts = () => {
(host.effectiveStatus || host.status).slice(1)} (host.effectiveStatus || host.status).slice(1)}
</div> </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": case "updates":
return ( return (
<button <button
@@ -1149,9 +1194,10 @@ const Hosts = () => {
// Filter to show only up-to-date hosts // Filter to show only up-to-date hosts
setStatusFilter("active"); setStatusFilter("active");
setShowFilters(true); setShowFilters(true);
// Use the upToDate URL filter // Clear conflicting filters and set upToDate filter
const newSearchParams = new URLSearchParams(window.location.search); const newSearchParams = new URLSearchParams(window.location.search);
newSearchParams.set("filter", "upToDate"); newSearchParams.set("filter", "upToDate");
newSearchParams.delete("reboot"); // Clear reboot filter when switching to upToDate
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true }); navigate(`/hosts?${newSearchParams.toString()}`, { replace: true });
}; };
@@ -1159,9 +1205,10 @@ const Hosts = () => {
// Filter to show hosts needing updates (regardless of status) // Filter to show hosts needing updates (regardless of status)
setStatusFilter("all"); setStatusFilter("all");
setShowFilters(true); 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); const newSearchParams = new URLSearchParams(window.location.search);
newSearchParams.set("filter", "needsUpdates"); newSearchParams.set("filter", "needsUpdates");
newSearchParams.delete("reboot"); // Clear reboot filter when switching to needsUpdates
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true }); navigate(`/hosts?${newSearchParams.toString()}`, { replace: true });
}; };
@@ -1169,9 +1216,10 @@ const Hosts = () => {
// Filter to show offline hosts (not connected via WebSocket) // Filter to show offline hosts (not connected via WebSocket)
setStatusFilter("all"); setStatusFilter("all");
setShowFilters(true); setShowFilters(true);
// Use a new URL filter for connection status // Clear conflicting filters and set offline filter
const newSearchParams = new URLSearchParams(window.location.search); const newSearchParams = new URLSearchParams(window.location.search);
newSearchParams.set("filter", "offline"); newSearchParams.set("filter", "offline");
newSearchParams.delete("reboot"); // Clear reboot filter when switching to offline
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true }); navigate(`/hosts?${newSearchParams.toString()}`, { replace: true });
}; };
@@ -1262,24 +1310,6 @@ const Hosts = () => {
</div> </div>
</div> </div>
</button> </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 <button
type="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" 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>
</div> </div>
</button> </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 <button
type="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" 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} {column.label}
{getSortIcon("updates")} {getSortIcon("updates")}
</button> </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" ? ( ) : column.id === "last_update" ? (
<button <button
type="button" type="button"

View File

@@ -8,6 +8,7 @@ import {
Download, Download,
Package, Package,
RefreshCw, RefreshCw,
RotateCcw,
Search, Search,
Server, Server,
Shield, 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"> <th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Last Updated Last Updated
</th> </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> </tr>
</thead> </thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600"> <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) ? formatRelativeTime(host.lastUpdate)
: "Never"} : "Never"}
</td> </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> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -6,6 +6,7 @@ import {
Database, Database,
Globe, Globe,
Lock, Lock,
RotateCcw,
Search, Search,
Server, Server,
Shield, 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"> <th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Last Update Last Update
</th> </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> </tr>
</thead> </thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600"> <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) ? formatRelativeTime(hostRepo.hosts.last_update)
: "Never"} : "Never"}
</td> </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> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -53,6 +53,7 @@ const Settings = () => {
}); });
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [toast, setToast] = useState(null);
// Tab management // Tab management
const [activeTab, setActiveTab] = useState("server"); const [activeTab, setActiveTab] = useState("server");
@@ -60,6 +61,56 @@ const Settings = () => {
// Get update notification state // Get update notification state
const { updateAvailable } = useUpdateNotification(); 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 // Tab configuration
const tabs = [ const tabs = [
{ id: "server", name: "Server Configuration", icon: Server }, { id: "server", name: "Server Configuration", icon: Server },
@@ -120,7 +171,7 @@ const Settings = () => {
}); });
// Helper function to get curl flags based on settings // Helper function to get curl flags based on settings
const _getCurlFlags = () => { const getCurlFlags = () => {
return settings?.ignore_ssl_self_signed ? "-sk" : "-s"; return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
}; };
@@ -442,6 +493,53 @@ const Settings = () => {
return ( return (
<div className="max-w-4xl mx-auto p-6"> <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"> <div className="mb-8">
<p className="text-secondary-600 dark:text-secondary-300"> <p className="text-secondary-600 dark:text-secondary-300">
Configure your PatchMon server settings. These settings will be used Configure your PatchMon server settings. These settings will be used
@@ -1159,19 +1257,74 @@ const Settings = () => {
To completely remove PatchMon from a host: To completely remove PatchMon from a host:
</p> </p>
{/* Go Agent Uninstall */} {/* Agent Removal Script - Standard */}
<div className="mb-3"> <div className="mb-3">
<div className="space-y-2"> <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="flex items-center gap-2">
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1"> <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> </div>
<button <button
type="button" type="button"
onClick={() => { onClick={async () => {
navigator.clipboard.writeText( try {
"sudo patchmon-agent uninstall", 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" 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> </button>
</div> </div>
<div className="text-xs text-red-600 dark:text-red-400"> <div className="text-xs text-red-600 dark:text-red-400">
Options: <code>--remove-config</code>,{" "} This removes: binaries, systemd/OpenRC services,
<code>--remove-logs</code>,{" "} configuration files, logs, crontab entries, and
<code>--remove-all</code>, <code>--force</code> backup files
</div> </div>
</div> </div>
</div> </div>
<p className="mt-2 text-xs"> <p className="mt-2 text-xs text-red-700 dark:text-red-400">
This command will remove all PatchMon files, Standard removal preserves backup files for
configuration, and crontab entries safety. Use complete removal to delete everything.
</p> </p>
</div> </div>
</div> </div>