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