Merge pull request #320 from PatchMon/feature/alpine

added reboot required flag
This commit is contained in:
9 Technology Group LTD
2025-11-16 22:54:15 +00:00
committed by GitHub
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
# 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
# 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}"
if [ "$REMOVE_BACKUPS" -eq 1 ]; then
info "Removing backup files (REMOVE_BACKUPS=1)..."
# 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!"

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?
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 {

View File

@@ -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",

View File

@@ -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: {

View File

@@ -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",

View File

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

View File

@@ -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({

View File

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

View File

@@ -24,6 +24,9 @@ server {
add_header X-Content-Type-Options nosniff always;
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 {

View File

@@ -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>

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 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({

View File

@@ -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>

View File

@@ -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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>