mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-02 04:53:40 +00:00
Compare commits
81 Commits
e6200f4f0e
...
renovate/v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1f506ae9d | ||
|
|
96aedbe761 | ||
|
|
3df2057f7e | ||
|
|
42f4e58bb4 | ||
|
|
12eef22912 | ||
|
|
c2121e3995 | ||
|
|
6792f96af9 | ||
|
|
1e617c8bb8 | ||
|
|
a76c5b8963 | ||
|
|
212b24b1c8 | ||
|
|
9fc3f4f9d1 | ||
|
|
3029278742 | ||
|
|
e4d6c1205c | ||
|
|
0f5272d12a | ||
|
|
5776d32e71 | ||
|
|
a11ff842eb | ||
|
|
48ce1951de | ||
|
|
9705e24b83 | ||
|
|
933c7a067e | ||
|
|
68f10c6c43 | ||
|
|
4b6f19c28e | ||
|
|
ae6afb0ef4 | ||
|
|
61523c9a44 | ||
|
|
3f9a5576ac | ||
|
|
e2dd7acca5 | ||
|
|
1c3b01f13c | ||
|
|
2c5a35b6c2 | ||
|
|
f42c53d34b | ||
|
|
95800e6d76 | ||
|
|
8d372411be | ||
|
|
de449c547f | ||
|
|
cd03f0e66a | ||
|
|
a8bd09be89 | ||
|
|
deb6bed1a6 | ||
|
|
3ae8422487 | ||
|
|
c98203a997 | ||
|
|
37c8f5fa76 | ||
|
|
0189a307ef | ||
|
|
50e546ee7e | ||
|
|
2174abf395 | ||
|
|
00abbc8c62 | ||
|
|
1350fd4e47 | ||
|
|
6b9a42fb0b | ||
|
|
3ee6f9aaa0 | ||
|
|
c9aef78912 | ||
|
|
8a5d61a7c1 | ||
|
|
fd2df0729e | ||
|
|
df502c676f | ||
|
|
d7f7b24f8f | ||
|
|
54cea6b20b | ||
|
|
1ef2308d56 | ||
|
|
af9b0d5d76 | ||
|
|
7b8c29860c | ||
|
|
fcd1b52e0e | ||
|
|
d78fb63c2d | ||
|
|
d3dc068c8e | ||
|
|
46e19fbfc2 | ||
|
|
5be8e01aa3 | ||
|
|
80a701cc33 | ||
|
|
293733dc0b | ||
|
|
c4d0d8bee8 | ||
|
|
30c89de134 | ||
|
|
c7ab40e4a2 | ||
|
|
4b35fc9ab9 | ||
|
|
191a1afada | ||
|
|
175f10b8b7 | ||
|
|
080bcbe22e | ||
|
|
3175ed79a5 | ||
|
|
fba6d0ede5 | ||
|
|
54a5012012 | ||
|
|
5004e062b4 | ||
|
|
44d52a5536 | ||
|
|
52c8ba6b03 | ||
|
|
9db563dec3 | ||
|
|
c328123bd3 | ||
|
|
46eb797ac3 | ||
|
|
c43afeb127 | ||
|
|
5b77a1328d | ||
|
|
9a40d5e6ee | ||
|
|
fdd0cfd619 | ||
|
|
de236f9ae2 |
34
.dockerignore
Normal file
34
.dockerignore
Normal file
@@ -0,0 +1,34 @@
|
||||
# Environment files
|
||||
**/.env
|
||||
**/.env.*
|
||||
**/env.example
|
||||
|
||||
# Node modules
|
||||
**/node_modules
|
||||
|
||||
# Logs
|
||||
**/logs
|
||||
**/*.log
|
||||
|
||||
# Git
|
||||
**/.git
|
||||
**/.gitignore
|
||||
|
||||
# IDE files
|
||||
**/.vscode
|
||||
**/.idea
|
||||
**/*.swp
|
||||
**/*.swo
|
||||
|
||||
# OS files
|
||||
**/.DS_Store
|
||||
**/Thumbs.db
|
||||
|
||||
# Build artifacts
|
||||
**/dist
|
||||
**/build
|
||||
**/coverage
|
||||
|
||||
# Temporary files
|
||||
**/tmp
|
||||
**/temp
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -139,6 +139,7 @@ playwright-report/
|
||||
test-results.xml
|
||||
test_*.sh
|
||||
test-*.sh
|
||||
*.code-workspace
|
||||
|
||||
# Package manager lock files (uncomment if you want to ignore them)
|
||||
# package-lock.json
|
||||
|
||||
1598
agents/patchmon-agent-legacy1-2-8.sh
Normal file
1598
agents/patchmon-agent-legacy1-2-8.sh
Normal file
File diff suppressed because it is too large
Load Diff
BIN
agents/patchmon-agent-linux-386
Executable file
BIN
agents/patchmon-agent-linux-386
Executable file
Binary file not shown.
BIN
agents/patchmon-agent-linux-amd64
Executable file
BIN
agents/patchmon-agent-linux-amd64
Executable file
Binary file not shown.
BIN
agents/patchmon-agent-linux-arm
Executable file
BIN
agents/patchmon-agent-linux-arm
Executable file
Binary file not shown.
BIN
agents/patchmon-agent-linux-arm64
Executable file
BIN
agents/patchmon-agent-linux-arm64
Executable file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
# PatchMon Docker Agent Script v1.2.9
|
||||
# PatchMon Docker Agent Script v1.3.0
|
||||
# This script collects Docker container and image information and sends it to PatchMon
|
||||
|
||||
# Configuration
|
||||
PATCHMON_SERVER="${PATCHMON_SERVER:-http://localhost:3001}"
|
||||
API_VERSION="v1"
|
||||
AGENT_VERSION="1.2.9"
|
||||
AGENT_VERSION="1.3.0"
|
||||
CONFIG_FILE="/etc/patchmon/agent.conf"
|
||||
CREDENTIALS_FILE="/etc/patchmon/credentials"
|
||||
LOG_FILE="/var/log/patchmon-docker-agent.log"
|
||||
|
||||
@@ -97,13 +97,22 @@ verify_datetime
|
||||
# Clean up old files (keep only last 3 of each type)
|
||||
cleanup_old_files() {
|
||||
# Clean up old credential backups
|
||||
ls -t /etc/patchmon/credentials.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||
ls -t /etc/patchmon/credentials.yml.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||
|
||||
# Clean up old config backups
|
||||
ls -t /etc/patchmon/config.yml.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||
|
||||
# Clean up old agent backups
|
||||
ls -t /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||
ls -t /usr/local/bin/patchmon-agent.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||
|
||||
# Clean up old log files
|
||||
ls -t /var/log/patchmon-agent.log.old.* 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||
ls -t /etc/patchmon/logs/patchmon-agent.log.old.* 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||
|
||||
# Clean up old shell script backups (if any exist)
|
||||
ls -t /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||
|
||||
# Clean up old credentials backups (if any exist)
|
||||
ls -t /etc/patchmon/credentials.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||
}
|
||||
|
||||
# Run cleanup at start
|
||||
@@ -127,6 +136,12 @@ if [[ -z "$PATCHMON_URL" ]] || [[ -z "$API_ID" ]] || [[ -z "$API_KEY" ]]; then
|
||||
error "Missing required parameters. This script should be called via the PatchMon web interface."
|
||||
fi
|
||||
|
||||
# Parse architecture parameter (default to amd64)
|
||||
ARCHITECTURE="${ARCHITECTURE:-amd64}"
|
||||
if [[ "$ARCHITECTURE" != "amd64" && "$ARCHITECTURE" != "386" && "$ARCHITECTURE" != "arm64" ]]; then
|
||||
error "Invalid architecture '$ARCHITECTURE'. Must be one of: amd64, 386, arm64"
|
||||
fi
|
||||
|
||||
# Check if --force flag is set (for bypassing broken packages)
|
||||
FORCE_INSTALL="${FORCE_INSTALL:-false}"
|
||||
if [[ "$*" == *"--force"* ]] || [[ "$FORCE_INSTALL" == "true" ]]; then
|
||||
@@ -142,6 +157,7 @@ info "🚀 Starting PatchMon Agent Installation..."
|
||||
info "📋 Server: $PATCHMON_URL"
|
||||
info "🔑 API ID: ${API_ID:0:16}..."
|
||||
info "🆔 Machine ID: ${MACHINE_ID:0:16}..."
|
||||
info "🏗️ Architecture: $ARCHITECTURE"
|
||||
|
||||
# Display diagnostic information
|
||||
echo ""
|
||||
@@ -150,6 +166,7 @@ echo " • URL: $PATCHMON_URL"
|
||||
echo " • CURL FLAGS: $CURL_FLAGS"
|
||||
echo " • API ID: ${API_ID:0:16}..."
|
||||
echo " • API Key: ${API_KEY:0:16}..."
|
||||
echo " • Architecture: $ARCHITECTURE"
|
||||
echo ""
|
||||
|
||||
# Install required dependencies
|
||||
@@ -294,67 +311,118 @@ else
|
||||
mkdir -p /etc/patchmon
|
||||
fi
|
||||
|
||||
# Step 2: Create credentials file
|
||||
info "🔐 Creating API credentials file..."
|
||||
# Step 2: Create configuration files
|
||||
info "🔐 Creating configuration files..."
|
||||
|
||||
# Check if config file already exists
|
||||
if [[ -f "/etc/patchmon/config.yml" ]]; then
|
||||
warning "⚠️ Config file already exists at /etc/patchmon/config.yml"
|
||||
warning "⚠️ Moving existing file out of the way for fresh installation"
|
||||
|
||||
# Clean up old config backups (keep only last 3)
|
||||
ls -t /etc/patchmon/config.yml.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||
|
||||
# Move existing file out of the way
|
||||
mv /etc/patchmon/config.yml /etc/patchmon/config.yml.backup.$(date +%Y%m%d_%H%M%S)
|
||||
info "📋 Moved existing config to: /etc/patchmon/config.yml.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
fi
|
||||
|
||||
# Check if credentials file already exists
|
||||
if [[ -f "/etc/patchmon/credentials" ]]; then
|
||||
warning "⚠️ Credentials file already exists at /etc/patchmon/credentials"
|
||||
if [[ -f "/etc/patchmon/credentials.yml" ]]; then
|
||||
warning "⚠️ Credentials file already exists at /etc/patchmon/credentials.yml"
|
||||
warning "⚠️ Moving existing file out of the way for fresh installation"
|
||||
|
||||
# Clean up old credential backups (keep only last 3)
|
||||
ls -t /etc/patchmon/credentials.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||
ls -t /etc/patchmon/credentials.yml.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||
|
||||
# Move existing file out of the way
|
||||
mv /etc/patchmon/credentials /etc/patchmon/credentials.backup.$(date +%Y%m%d_%H%M%S)
|
||||
info "📋 Moved existing credentials to: /etc/patchmon/credentials.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
mv /etc/patchmon/credentials.yml /etc/patchmon/credentials.yml.backup.$(date +%Y%m%d_%H%M%S)
|
||||
info "📋 Moved existing credentials to: /etc/patchmon/credentials.yml.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
fi
|
||||
|
||||
cat > /etc/patchmon/credentials << EOF
|
||||
# Clean up old credentials file if it exists (from previous installations)
|
||||
if [[ -f "/etc/patchmon/credentials" ]]; then
|
||||
warning "⚠️ Found old credentials file, removing it..."
|
||||
rm -f /etc/patchmon/credentials
|
||||
info "📋 Removed old credentials file"
|
||||
fi
|
||||
|
||||
# Create main config file
|
||||
cat > /etc/patchmon/config.yml << EOF
|
||||
# PatchMon Agent Configuration
|
||||
# Generated on $(date)
|
||||
patchmon_server: "$PATCHMON_URL"
|
||||
api_version: "v1"
|
||||
credentials_file: "/etc/patchmon/credentials.yml"
|
||||
log_file: "/etc/patchmon/logs/patchmon-agent.log"
|
||||
log_level: "info"
|
||||
skip_ssl_verify: ${SKIP_SSL_VERIFY:-false}
|
||||
EOF
|
||||
|
||||
# Create credentials file
|
||||
cat > /etc/patchmon/credentials.yml << EOF
|
||||
# PatchMon API Credentials
|
||||
# Generated on $(date)
|
||||
PATCHMON_URL="$PATCHMON_URL"
|
||||
API_ID="$API_ID"
|
||||
API_KEY="$API_KEY"
|
||||
api_id: "$API_ID"
|
||||
api_key: "$API_KEY"
|
||||
EOF
|
||||
chmod 600 /etc/patchmon/credentials
|
||||
|
||||
# Step 3: Download the agent script using API credentials
|
||||
info "📥 Downloading PatchMon agent script..."
|
||||
chmod 600 /etc/patchmon/config.yml
|
||||
chmod 600 /etc/patchmon/credentials.yml
|
||||
|
||||
# Check if agent script already exists
|
||||
if [[ -f "/usr/local/bin/patchmon-agent.sh" ]]; then
|
||||
warning "⚠️ Agent script already exists at /usr/local/bin/patchmon-agent.sh"
|
||||
# Step 3: Download the PatchMon agent binary using API credentials
|
||||
info "📥 Downloading PatchMon agent binary..."
|
||||
|
||||
# Determine the binary filename based on architecture
|
||||
BINARY_NAME="patchmon-agent-linux-${ARCHITECTURE}"
|
||||
|
||||
# Check if agent binary already exists
|
||||
if [[ -f "/usr/local/bin/patchmon-agent" ]]; then
|
||||
warning "⚠️ Agent binary already exists at /usr/local/bin/patchmon-agent"
|
||||
warning "⚠️ Moving existing file out of the way for fresh installation"
|
||||
|
||||
# Clean up old agent backups (keep only last 3)
|
||||
ls -t /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||
ls -t /usr/local/bin/patchmon-agent.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||
|
||||
# Move existing file out of the way
|
||||
mv /usr/local/bin/patchmon-agent.sh /usr/local/bin/patchmon-agent.sh.backup.$(date +%Y%m%d_%H%M%S)
|
||||
info "📋 Moved existing agent to: /usr/local/bin/patchmon-agent.sh.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
mv /usr/local/bin/patchmon-agent /usr/local/bin/patchmon-agent.backup.$(date +%Y%m%d_%H%M%S)
|
||||
info "📋 Moved existing agent to: /usr/local/bin/patchmon-agent.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
fi
|
||||
|
||||
# Clean up old shell script if it exists (from previous installations)
|
||||
if [[ -f "/usr/local/bin/patchmon-agent.sh" ]]; then
|
||||
warning "⚠️ Found old shell script agent, removing it..."
|
||||
rm -f /usr/local/bin/patchmon-agent.sh
|
||||
info "📋 Removed old shell script agent"
|
||||
fi
|
||||
|
||||
# Download the binary
|
||||
curl $CURL_FLAGS \
|
||||
-H "X-API-ID: $API_ID" \
|
||||
-H "X-API-KEY: $API_KEY" \
|
||||
"$PATCHMON_URL/api/v1/hosts/agent/download" \
|
||||
-o /usr/local/bin/patchmon-agent.sh
|
||||
"$PATCHMON_URL/api/v1/hosts/agent/download?arch=$ARCHITECTURE&force=binary" \
|
||||
-o /usr/local/bin/patchmon-agent
|
||||
|
||||
chmod +x /usr/local/bin/patchmon-agent.sh
|
||||
chmod +x /usr/local/bin/patchmon-agent
|
||||
|
||||
# Get the agent version from the downloaded script
|
||||
AGENT_VERSION=$(grep '^AGENT_VERSION=' /usr/local/bin/patchmon-agent.sh | cut -d'"' -f2 2>/dev/null || echo "Unknown")
|
||||
# Get the agent version from the binary
|
||||
AGENT_VERSION=$(/usr/local/bin/patchmon-agent version 2>/dev/null || echo "Unknown")
|
||||
info "📋 Agent version: $AGENT_VERSION"
|
||||
|
||||
# Handle existing log files and create log directory
|
||||
info "📁 Setting up log directory..."
|
||||
|
||||
# Create log directory if it doesn't exist
|
||||
mkdir -p /etc/patchmon/logs
|
||||
|
||||
# Handle existing log files
|
||||
if [[ -f "/var/log/patchmon-agent.log" ]]; then
|
||||
warning "⚠️ Existing log file found at /var/log/patchmon-agent.log"
|
||||
if [[ -f "/etc/patchmon/logs/patchmon-agent.log" ]]; then
|
||||
warning "⚠️ Existing log file found at /etc/patchmon/logs/patchmon-agent.log"
|
||||
warning "⚠️ Rotating log file for fresh start"
|
||||
|
||||
# Rotate the log file
|
||||
mv /var/log/patchmon-agent.log /var/log/patchmon-agent.log.old.$(date +%Y%m%d_%H%M%S)
|
||||
info "📋 Log file rotated to: /var/log/patchmon-agent.log.old.$(date +%Y%m%d_%H%M%S)"
|
||||
mv /etc/patchmon/logs/patchmon-agent.log /etc/patchmon/logs/patchmon-agent.log.old.$(date +%Y%m%d_%H%M%S)
|
||||
info "📋 Log file rotated to: /etc/patchmon/logs/patchmon-agent.log.old.$(date +%Y%m%d_%H%M%S)"
|
||||
fi
|
||||
|
||||
# Step 4: Test the configuration
|
||||
@@ -386,19 +454,76 @@ if [[ "$http_code" == "200" ]]; then
|
||||
fi
|
||||
|
||||
info "🧪 Testing API credentials and connectivity..."
|
||||
if /usr/local/bin/patchmon-agent.sh test; then
|
||||
if /usr/local/bin/patchmon-agent ping; then
|
||||
success "✅ TEST: API credentials are valid and server is reachable"
|
||||
else
|
||||
error "❌ Failed to validate API credentials or reach server"
|
||||
fi
|
||||
|
||||
# Step 5: Send initial data and setup automated updates
|
||||
# Step 5: Send initial data and setup systemd service
|
||||
info "📊 Sending initial package data to server..."
|
||||
if /usr/local/bin/patchmon-agent.sh update; then
|
||||
if /usr/local/bin/patchmon-agent report; then
|
||||
success "✅ UPDATE: Initial package data sent successfully"
|
||||
info "✅ Automated updates configured by agent"
|
||||
else
|
||||
warning "⚠️ Failed to send initial data. You can retry later with: /usr/local/bin/patchmon-agent.sh update"
|
||||
warning "⚠️ Failed to send initial data. You can retry later with: /usr/local/bin/patchmon-agent report"
|
||||
fi
|
||||
|
||||
# Step 6: Setup systemd service for WebSocket connection
|
||||
info "🔧 Setting up systemd service..."
|
||||
|
||||
# Stop and disable existing service if it exists
|
||||
if systemctl is-active --quiet patchmon-agent.service 2>/dev/null; then
|
||||
warning "⚠️ Stopping existing PatchMon agent service..."
|
||||
systemctl stop patchmon-agent.service
|
||||
fi
|
||||
|
||||
if systemctl is-enabled --quiet patchmon-agent.service 2>/dev/null; then
|
||||
warning "⚠️ Disabling existing PatchMon agent service..."
|
||||
systemctl disable patchmon-agent.service
|
||||
fi
|
||||
|
||||
# Create systemd service file
|
||||
cat > /etc/systemd/system/patchmon-agent.service << EOF
|
||||
[Unit]
|
||||
Description=PatchMon Agent Service
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/local/bin/patchmon-agent serve
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
WorkingDirectory=/etc/patchmon
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=patchmon-agent
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# Clean up old crontab entries if they exist (from previous installations)
|
||||
if crontab -l 2>/dev/null | grep -q "patchmon-agent"; then
|
||||
warning "⚠️ Found old crontab entries, removing them..."
|
||||
crontab -l 2>/dev/null | grep -v "patchmon-agent" | crontab -
|
||||
info "📋 Removed old crontab entries"
|
||||
fi
|
||||
|
||||
# Reload systemd and enable/start the service
|
||||
systemctl daemon-reload
|
||||
systemctl enable patchmon-agent.service
|
||||
systemctl start patchmon-agent.service
|
||||
|
||||
# Check if service started successfully
|
||||
if systemctl is-active --quiet patchmon-agent.service; then
|
||||
success "✅ PatchMon Agent service started successfully"
|
||||
info "🔗 WebSocket connection established"
|
||||
else
|
||||
warning "⚠️ Service may have failed to start. Check status with: systemctl status patchmon-agent"
|
||||
fi
|
||||
|
||||
# Installation complete
|
||||
@@ -406,14 +531,16 @@ success "🎉 PatchMon Agent installation completed successfully!"
|
||||
echo ""
|
||||
echo -e "${GREEN}📋 Installation Summary:${NC}"
|
||||
echo " • Configuration directory: /etc/patchmon"
|
||||
echo " • Agent installed: /usr/local/bin/patchmon-agent.sh"
|
||||
echo " • Agent binary installed: /usr/local/bin/patchmon-agent"
|
||||
echo " • Architecture: $ARCHITECTURE"
|
||||
echo " • Dependencies installed: jq, curl, bc"
|
||||
echo " • Automated updates configured via crontab"
|
||||
echo " • Systemd service configured and running"
|
||||
echo " • API credentials configured and tested"
|
||||
echo " • Update schedule managed by agent"
|
||||
echo " • WebSocket connection established"
|
||||
echo " • Logs directory: /etc/patchmon/logs"
|
||||
|
||||
# Check for moved files and show them
|
||||
MOVED_FILES=$(ls /etc/patchmon/credentials.backup.* /usr/local/bin/patchmon-agent.sh.backup.* /var/log/patchmon-agent.log.old.* 2>/dev/null || true)
|
||||
MOVED_FILES=$(ls /etc/patchmon/credentials.yml.backup.* /etc/patchmon/config.yml.backup.* /usr/local/bin/patchmon-agent.backup.* /etc/patchmon/logs/patchmon-agent.log.old.* /usr/local/bin/patchmon-agent.sh.backup.* /etc/patchmon/credentials.backup.* 2>/dev/null || true)
|
||||
if [[ -n "$MOVED_FILES" ]]; then
|
||||
echo ""
|
||||
echo -e "${YELLOW}📋 Files Moved for Fresh Installation:${NC}"
|
||||
@@ -426,8 +553,11 @@ fi
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}🔧 Management Commands:${NC}"
|
||||
echo " • Test connection: /usr/local/bin/patchmon-agent.sh test"
|
||||
echo " • Manual update: /usr/local/bin/patchmon-agent.sh update"
|
||||
echo " • Check status: /usr/local/bin/patchmon-agent.sh diagnostics"
|
||||
echo " • Test connection: /usr/local/bin/patchmon-agent ping"
|
||||
echo " • Manual report: /usr/local/bin/patchmon-agent report"
|
||||
echo " • Check status: /usr/local/bin/patchmon-agent diagnostics"
|
||||
echo " • Service status: systemctl status patchmon-agent"
|
||||
echo " • Service logs: journalctl -u patchmon-agent -f"
|
||||
echo " • Restart service: systemctl restart patchmon-agent"
|
||||
echo ""
|
||||
success "✅ Your system is now being monitored by PatchMon!"
|
||||
|
||||
@@ -153,6 +153,32 @@ while IFS= read -r line; do
|
||||
ip_address=$(timeout 5 pct exec "$vmid" -- hostname -I 2>/dev/null </dev/null | awk '{print $1}' || echo "unknown")
|
||||
os_info=$(timeout 5 pct exec "$vmid" -- cat /etc/os-release 2>/dev/null </dev/null | grep "^PRETTY_NAME=" | cut -d'"' -f2 || echo "unknown")
|
||||
|
||||
# Detect container architecture
|
||||
debug " Detecting container architecture..."
|
||||
arch_raw=$(timeout 5 pct exec "$vmid" -- uname -m 2>/dev/null </dev/null || echo "unknown")
|
||||
|
||||
# Map architecture to supported values
|
||||
case "$arch_raw" in
|
||||
"x86_64")
|
||||
architecture="amd64"
|
||||
;;
|
||||
"i386"|"i686")
|
||||
architecture="386"
|
||||
;;
|
||||
"aarch64"|"arm64")
|
||||
architecture="arm64"
|
||||
;;
|
||||
"armv7l"|"armv6l"|"arm")
|
||||
architecture="arm"
|
||||
;;
|
||||
*)
|
||||
warn " ⚠ Unknown architecture '$arch_raw', defaulting to amd64"
|
||||
architecture="amd64"
|
||||
;;
|
||||
esac
|
||||
|
||||
debug " Detected architecture: $arch_raw -> $architecture"
|
||||
|
||||
# Get machine ID from container
|
||||
machine_id=$(timeout 5 pct exec "$vmid" -- bash -c "cat /etc/machine-id 2>/dev/null || cat /var/lib/dbus/machine-id 2>/dev/null || echo 'proxmox-lxc-$vmid-'$(cat /proc/sys/kernel/random/uuid)" </dev/null 2>/dev/null || echo "proxmox-lxc-$vmid-unknown")
|
||||
|
||||
@@ -161,6 +187,7 @@ while IFS= read -r line; do
|
||||
info " Hostname: $hostname"
|
||||
info " IP Address: $ip_address"
|
||||
info " OS: $os_info"
|
||||
info " Architecture: $architecture ($arch_raw)"
|
||||
info " Machine ID: ${machine_id:0:16}..."
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
@@ -244,12 +271,13 @@ while IFS= read -r line; do
|
||||
# Install PatchMon agent in container
|
||||
info " Installing PatchMon agent..."
|
||||
|
||||
# Build install URL with force flag if enabled
|
||||
install_url="$PATCHMON_URL/api/v1/hosts/install"
|
||||
# Build install URL with force flag and architecture if enabled
|
||||
install_url="$PATCHMON_URL/api/v1/hosts/install?arch=$architecture"
|
||||
if [[ "$FORCE_INSTALL" == "true" ]]; then
|
||||
install_url="$install_url?force=true"
|
||||
install_url="$install_url&force=true"
|
||||
info " Using force mode - will bypass broken packages"
|
||||
fi
|
||||
info " Using architecture: $architecture"
|
||||
|
||||
# Reset exit code for this container
|
||||
install_exit_code=0
|
||||
@@ -400,7 +428,7 @@ if [[ ${#dpkg_error_containers[@]} -gt 0 ]]; then
|
||||
-H \"X-API-ID: $api_id\" \
|
||||
-H \"X-API-KEY: $api_key\" \
|
||||
-o patchmon-install.sh \
|
||||
'$PATCHMON_URL/api/v1/hosts/install' && \
|
||||
'$PATCHMON_URL/api/v1/hosts/install?arch=$architecture' && \
|
||||
bash patchmon-install.sh && \
|
||||
rm -f patchmon-install.sh
|
||||
" 2>&1 </dev/null) || install_exit_code=$?
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
# Database Configuration
|
||||
DATABASE_URL="postgresql://patchmon_user:p@tchm0n_p@55@localhost:5432/patchmon_db"
|
||||
DATABASE_URL="postgresql://patchmon_user:your-password-here@localhost:5432/patchmon_db"
|
||||
PM_DB_CONN_MAX_ATTEMPTS=30
|
||||
PM_DB_CONN_WAIT_INTERVAL=2
|
||||
|
||||
# Database Connection Pool Configuration (Prisma)
|
||||
DB_CONNECTION_LIMIT=30 # Maximum connections per instance (default: 30)
|
||||
DB_POOL_TIMEOUT=20 # Seconds to wait for available connection (default: 20)
|
||||
DB_CONNECT_TIMEOUT=10 # Seconds to wait for initial connection (default: 10)
|
||||
DB_IDLE_TIMEOUT=300 # Seconds before closing idle connections (default: 300)
|
||||
DB_MAX_LIFETIME=1800 # Maximum lifetime of a connection in seconds (default: 1800)
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=your-secure-random-secret-key-change-this-in-production
|
||||
JWT_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
NODE_ENV=production
|
||||
|
||||
# API Configuration
|
||||
API_VERSION=v1
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# Session Configuration
|
||||
SESSION_INACTIVITY_TIMEOUT_MINUTES=30
|
||||
|
||||
# User Configuration
|
||||
DEFAULT_USER_ROLE=user
|
||||
|
||||
# Rate Limiting (times in milliseconds)
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX=5000
|
||||
@@ -19,20 +39,18 @@ AUTH_RATE_LIMIT_MAX=500
|
||||
AGENT_RATE_LIMIT_WINDOW_MS=60000
|
||||
AGENT_RATE_LIMIT_MAX=1000
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_USER=your-redis-username-here
|
||||
REDIS_PASSWORD=your-redis-password-here
|
||||
REDIS_DB=0
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
ENABLE_LOGGING=true
|
||||
|
||||
# User Registration
|
||||
DEFAULT_USER_ROLE=user
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=your-secure-random-secret-key-change-this-in-production
|
||||
JWT_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
SESSION_INACTIVITY_TIMEOUT_MINUTES=30
|
||||
|
||||
# TFA Configuration
|
||||
# TFA Configuration (optional - used if TFA is enabled)
|
||||
TFA_REMEMBER_ME_EXPIRES_IN=30d
|
||||
TFA_MAX_REMEMBER_SESSIONS=5
|
||||
TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=3
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "patchmon-backend",
|
||||
"version": "1.2.9",
|
||||
"version": "1.3.1",
|
||||
"description": "Backend API for Linux Patch Monitoring System",
|
||||
"license": "AGPL-3.0",
|
||||
"main": "src/server.js",
|
||||
@@ -14,11 +14,13 @@
|
||||
"db:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.13.0",
|
||||
"@bull-board/express": "^6.13.0",
|
||||
"@bull-board/api": "^6.13.1",
|
||||
"@bull-board/express": "^6.13.1",
|
||||
"@prisma/client": "^6.1.0",
|
||||
"axios": "^1.7.9",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.61.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
@@ -31,7 +33,8 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"speakeasy": "^2.0.0",
|
||||
"uuid": "^11.0.3",
|
||||
"winston": "^3.17.0"
|
||||
"winston": "^3.17.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
|
||||
@@ -4,17 +4,9 @@
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
old_migration_exists boolean := false;
|
||||
table_exists boolean := false;
|
||||
failed_migration_exists boolean := false;
|
||||
new_migration_exists boolean := false;
|
||||
migration_exists boolean := false;
|
||||
BEGIN
|
||||
-- Check if the old migration name exists
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM _prisma_migrations
|
||||
WHERE migration_name = 'add_user_sessions'
|
||||
) INTO old_migration_exists;
|
||||
|
||||
-- Check if user_sessions table exists
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
@@ -22,54 +14,14 @@ BEGIN
|
||||
AND table_name = 'user_sessions'
|
||||
) INTO table_exists;
|
||||
|
||||
-- Check if there's a failed migration attempt
|
||||
-- Check if the migration record already exists
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM _prisma_migrations
|
||||
WHERE migration_name = '20251005000000_add_user_sessions'
|
||||
AND finished_at IS NULL
|
||||
) INTO failed_migration_exists;
|
||||
WHERE migration_name = '20251005000000_add_user_sessions'
|
||||
) INTO migration_exists;
|
||||
|
||||
-- Check if the new migration already exists and is successful
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM _prisma_migrations
|
||||
WHERE migration_name = '20251005000000_add_user_sessions'
|
||||
AND finished_at IS NOT NULL
|
||||
) INTO new_migration_exists;
|
||||
|
||||
-- FIRST: Handle failed migration (must be marked as rolled back)
|
||||
IF failed_migration_exists THEN
|
||||
RAISE NOTICE 'Found failed migration attempt - marking as rolled back';
|
||||
|
||||
-- Mark the failed migration as rolled back (required by Prisma)
|
||||
UPDATE _prisma_migrations
|
||||
SET rolled_back_at = NOW()
|
||||
WHERE migration_name = '20251005000000_add_user_sessions'
|
||||
AND finished_at IS NULL;
|
||||
|
||||
RAISE NOTICE 'Failed migration marked as rolled back';
|
||||
|
||||
-- If table exists, it means the migration partially succeeded
|
||||
IF table_exists THEN
|
||||
RAISE NOTICE 'Table exists - migration was partially successful, will be handled by next migration';
|
||||
ELSE
|
||||
RAISE NOTICE 'Table does not exist - migration will retry after rollback';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- SECOND: Handle old migration name (1.2.7 -> 1.2.8+ upgrade)
|
||||
IF old_migration_exists AND table_exists THEN
|
||||
RAISE NOTICE 'Found 1.2.7 migration "add_user_sessions" - updating to timestamped version';
|
||||
|
||||
-- Update the old migration name to the new timestamped version
|
||||
UPDATE _prisma_migrations
|
||||
SET migration_name = '20251005000000_add_user_sessions'
|
||||
WHERE migration_name = 'add_user_sessions';
|
||||
|
||||
RAISE NOTICE 'Migration name updated: add_user_sessions -> 20251005000000_add_user_sessions';
|
||||
END IF;
|
||||
|
||||
-- THIRD: Handle case where table exists but no migration record exists (1.2.7 upgrade scenario)
|
||||
IF table_exists AND NOT old_migration_exists AND NOT new_migration_exists THEN
|
||||
-- If table exists but no migration record, create one
|
||||
IF table_exists AND NOT migration_exists THEN
|
||||
RAISE NOTICE 'Table exists but no migration record found - creating migration record for 1.2.7 upgrade';
|
||||
|
||||
-- Insert a successful migration record for the existing table
|
||||
@@ -94,26 +46,19 @@ BEGIN
|
||||
);
|
||||
|
||||
RAISE NOTICE 'Migration record created for existing table';
|
||||
ELSIF table_exists AND migration_exists THEN
|
||||
RAISE NOTICE 'Table exists and migration record exists - no action needed';
|
||||
ELSE
|
||||
RAISE NOTICE 'Table does not exist - migration will proceed normally';
|
||||
END IF;
|
||||
|
||||
-- FOURTH: If we have a rolled back migration and table exists, mark it as applied
|
||||
IF failed_migration_exists AND table_exists THEN
|
||||
RAISE NOTICE 'Migration was rolled back but table exists - marking as successfully applied';
|
||||
|
||||
-- Update the rolled back migration to be successful
|
||||
-- Additional check: If we have any old migration names, update them
|
||||
IF EXISTS (SELECT 1 FROM _prisma_migrations WHERE migration_name = 'add_user_sessions') THEN
|
||||
RAISE NOTICE 'Found old migration name - updating to new format';
|
||||
UPDATE _prisma_migrations
|
||||
SET
|
||||
finished_at = NOW(),
|
||||
rolled_back_at = NULL,
|
||||
logs = 'Reconciled from failed state - table already exists'
|
||||
WHERE migration_name = '20251005000000_add_user_sessions';
|
||||
|
||||
RAISE NOTICE 'Migration marked as successfully applied';
|
||||
END IF;
|
||||
|
||||
-- If no issues found
|
||||
IF NOT old_migration_exists AND NOT failed_migration_exists AND NOT (table_exists AND NOT new_migration_exists) THEN
|
||||
RAISE NOTICE 'No migration reconciliation needed';
|
||||
SET migration_name = '20251005000000_add_user_sessions'
|
||||
WHERE migration_name = 'add_user_sessions';
|
||||
RAISE NOTICE 'Old migration name updated';
|
||||
END IF;
|
||||
|
||||
END $$;
|
||||
|
||||
@@ -1,31 +1,106 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_sessions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"refresh_token" TEXT NOT NULL,
|
||||
"access_token_hash" TEXT,
|
||||
"ip_address" TEXT,
|
||||
"user_agent" TEXT,
|
||||
"last_activity" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"is_revoked" BOOLEAN NOT NULL DEFAULT false,
|
||||
-- CreateTable (with existence check for 1.2.7 compatibility)
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Check if table already exists (from 1.2.7 installation)
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'user_sessions'
|
||||
) THEN
|
||||
-- Table doesn't exist, create it
|
||||
CREATE TABLE "user_sessions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"refresh_token" TEXT NOT NULL,
|
||||
"access_token_hash" TEXT,
|
||||
"ip_address" TEXT,
|
||||
"user_agent" TEXT,
|
||||
"last_activity" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"is_revoked" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "user_sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CONSTRAINT "user_sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
RAISE NOTICE 'Created user_sessions table';
|
||||
ELSE
|
||||
RAISE NOTICE 'user_sessions table already exists, skipping creation';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_sessions_refresh_token_key" ON "user_sessions"("refresh_token");
|
||||
-- CreateIndex (with existence check)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'user_sessions'
|
||||
AND indexname = 'user_sessions_refresh_token_key'
|
||||
) THEN
|
||||
CREATE UNIQUE INDEX "user_sessions_refresh_token_key" ON "user_sessions"("refresh_token");
|
||||
RAISE NOTICE 'Created user_sessions_refresh_token_key index';
|
||||
ELSE
|
||||
RAISE NOTICE 'user_sessions_refresh_token_key index already exists, skipping';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_sessions_user_id_idx" ON "user_sessions"("user_id");
|
||||
-- CreateIndex (with existence check)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'user_sessions'
|
||||
AND indexname = 'user_sessions_user_id_idx'
|
||||
) THEN
|
||||
CREATE INDEX "user_sessions_user_id_idx" ON "user_sessions"("user_id");
|
||||
RAISE NOTICE 'Created user_sessions_user_id_idx index';
|
||||
ELSE
|
||||
RAISE NOTICE 'user_sessions_user_id_idx index already exists, skipping';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_sessions_refresh_token_idx" ON "user_sessions"("refresh_token");
|
||||
-- CreateIndex (with existence check)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'user_sessions'
|
||||
AND indexname = 'user_sessions_refresh_token_idx'
|
||||
) THEN
|
||||
CREATE INDEX "user_sessions_refresh_token_idx" ON "user_sessions"("refresh_token");
|
||||
RAISE NOTICE 'Created user_sessions_refresh_token_idx index';
|
||||
ELSE
|
||||
RAISE NOTICE 'user_sessions_refresh_token_idx index already exists, skipping';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_sessions_expires_at_idx" ON "user_sessions"("expires_at");
|
||||
-- CreateIndex (with existence check)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'user_sessions'
|
||||
AND indexname = 'user_sessions_expires_at_idx'
|
||||
) THEN
|
||||
CREATE INDEX "user_sessions_expires_at_idx" ON "user_sessions"("expires_at");
|
||||
RAISE NOTICE 'Created user_sessions_expires_at_idx index';
|
||||
ELSE
|
||||
RAISE NOTICE 'user_sessions_expires_at_idx index already exists, skipping';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
-- AddForeignKey (with existence check)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE table_name = 'user_sessions'
|
||||
AND constraint_name = 'user_sessions_user_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
RAISE NOTICE 'Created user_sessions_user_id_fkey foreign key';
|
||||
ELSE
|
||||
RAISE NOTICE 'user_sessions_user_id_fkey foreign key already exists, skipping';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "job_history" (
|
||||
"id" TEXT NOT NULL,
|
||||
"job_id" TEXT NOT NULL,
|
||||
"queue_name" TEXT NOT NULL,
|
||||
"job_name" TEXT NOT NULL,
|
||||
"host_id" TEXT,
|
||||
"api_id" TEXT,
|
||||
"status" TEXT NOT NULL,
|
||||
"attempt_number" INTEGER NOT NULL DEFAULT 1,
|
||||
"error_message" TEXT,
|
||||
"output" JSONB,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"completed_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "job_history_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "job_history_job_id_idx" ON "job_history"("job_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "job_history_queue_name_idx" ON "job_history"("queue_name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "job_history_host_id_idx" ON "job_history"("host_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "job_history_api_id_idx" ON "job_history"("api_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "job_history_status_idx" ON "job_history"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "job_history_created_at_idx" ON "job_history"("created_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "job_history" ADD CONSTRAINT "job_history_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "host_group_memberships" (
|
||||
"id" TEXT NOT NULL,
|
||||
"host_id" TEXT NOT NULL,
|
||||
"host_group_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "host_group_memberships_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "host_group_memberships_host_id_host_group_id_key" ON "host_group_memberships"("host_id", "host_group_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "host_group_memberships_host_id_idx" ON "host_group_memberships"("host_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "host_group_memberships_host_group_id_idx" ON "host_group_memberships"("host_group_id");
|
||||
|
||||
-- Migrate existing data from hosts.host_group_id to host_group_memberships
|
||||
INSERT INTO "host_group_memberships" ("id", "host_id", "host_group_id", "created_at")
|
||||
SELECT
|
||||
gen_random_uuid()::text as "id",
|
||||
"id" as "host_id",
|
||||
"host_group_id" as "host_group_id",
|
||||
CURRENT_TIMESTAMP as "created_at"
|
||||
FROM "hosts"
|
||||
WHERE "host_group_id" IS NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "host_group_memberships" ADD CONSTRAINT "host_group_memberships_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "host_group_memberships" ADD CONSTRAINT "host_group_memberships_host_group_id_fkey" FOREIGN KEY ("host_group_id") REFERENCES "host_groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "hosts" DROP CONSTRAINT IF EXISTS "hosts_host_group_id_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "hosts_host_group_id_idx";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "hosts" DROP COLUMN "host_group_id";
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
-- Add color_theme field to settings table for customizable app theming
|
||||
ALTER TABLE "settings" ADD COLUMN "color_theme" TEXT NOT NULL DEFAULT 'default';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- AddMetricsTelemetry
|
||||
-- Add anonymous metrics and telemetry fields to settings table
|
||||
|
||||
-- Add metrics fields to settings table
|
||||
ALTER TABLE "settings" ADD COLUMN "metrics_enabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "settings" ADD COLUMN "metrics_anonymous_id" TEXT;
|
||||
ALTER TABLE "settings" ADD COLUMN "metrics_last_sent" TIMESTAMP(3);
|
||||
|
||||
-- Generate UUID for existing records (if any exist)
|
||||
-- This will use PostgreSQL's gen_random_uuid() function
|
||||
UPDATE "settings"
|
||||
SET "metrics_anonymous_id" = gen_random_uuid()::text
|
||||
WHERE "metrics_anonymous_id" IS NULL;
|
||||
|
||||
@@ -27,10 +27,23 @@ model host_groups {
|
||||
color String? @default("#3B82F6")
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime
|
||||
hosts hosts[]
|
||||
host_group_memberships host_group_memberships[]
|
||||
auto_enrollment_tokens auto_enrollment_tokens[]
|
||||
}
|
||||
|
||||
model host_group_memberships {
|
||||
id String @id
|
||||
host_id String
|
||||
host_group_id String
|
||||
created_at DateTime @default(now())
|
||||
hosts hosts @relation(fields: [host_id], references: [id], onDelete: Cascade)
|
||||
host_groups host_groups @relation(fields: [host_group_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([host_id, host_group_id])
|
||||
@@index([host_id])
|
||||
@@index([host_group_id])
|
||||
}
|
||||
|
||||
model host_packages {
|
||||
id String @id
|
||||
host_id String
|
||||
@@ -67,40 +80,40 @@ model host_repositories {
|
||||
}
|
||||
|
||||
model hosts {
|
||||
id String @id
|
||||
machine_id String @unique
|
||||
friendly_name String
|
||||
ip String?
|
||||
os_type String
|
||||
os_version String
|
||||
architecture String?
|
||||
last_update DateTime @default(now())
|
||||
status String @default("active")
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime
|
||||
api_id String @unique
|
||||
api_key String @unique
|
||||
host_group_id String?
|
||||
agent_version String?
|
||||
auto_update Boolean @default(true)
|
||||
cpu_cores Int?
|
||||
cpu_model String?
|
||||
disk_details Json?
|
||||
dns_servers Json?
|
||||
gateway_ip String?
|
||||
hostname String?
|
||||
kernel_version String?
|
||||
load_average Json?
|
||||
network_interfaces Json?
|
||||
ram_installed Int?
|
||||
selinux_status String?
|
||||
swap_size Int?
|
||||
system_uptime String?
|
||||
notes String?
|
||||
host_packages host_packages[]
|
||||
host_repositories host_repositories[]
|
||||
host_groups host_groups? @relation(fields: [host_group_id], references: [id])
|
||||
update_history update_history[]
|
||||
id String @id
|
||||
machine_id String @unique
|
||||
friendly_name String
|
||||
ip String?
|
||||
os_type String
|
||||
os_version String
|
||||
architecture String?
|
||||
last_update DateTime @default(now())
|
||||
status String @default("active")
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime
|
||||
api_id String @unique
|
||||
api_key String @unique
|
||||
agent_version String?
|
||||
auto_update Boolean @default(true)
|
||||
cpu_cores Int?
|
||||
cpu_model String?
|
||||
disk_details Json?
|
||||
dns_servers Json?
|
||||
gateway_ip String?
|
||||
hostname String?
|
||||
kernel_version String?
|
||||
load_average Json?
|
||||
network_interfaces Json?
|
||||
ram_installed Int?
|
||||
selinux_status String?
|
||||
swap_size Int?
|
||||
system_uptime String?
|
||||
notes String?
|
||||
host_packages host_packages[]
|
||||
host_repositories host_repositories[]
|
||||
host_group_memberships host_group_memberships[]
|
||||
update_history update_history[]
|
||||
job_history job_history[]
|
||||
|
||||
@@index([machine_id])
|
||||
@@index([friendly_name])
|
||||
@@ -157,27 +170,31 @@ model role_permissions {
|
||||
}
|
||||
|
||||
model settings {
|
||||
id String @id
|
||||
server_url String @default("http://localhost:3001")
|
||||
server_protocol String @default("http")
|
||||
server_host String @default("localhost")
|
||||
server_port Int @default(3001)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime
|
||||
update_interval Int @default(60)
|
||||
auto_update Boolean @default(false)
|
||||
github_repo_url String @default("git@github.com:9technologygroup/patchmon.net.git")
|
||||
ssh_key_path String?
|
||||
repository_type String @default("public")
|
||||
last_update_check DateTime?
|
||||
latest_version String?
|
||||
update_available Boolean @default(false)
|
||||
signup_enabled Boolean @default(false)
|
||||
default_user_role String @default("user")
|
||||
ignore_ssl_self_signed Boolean @default(false)
|
||||
logo_dark String? @default("/assets/logo_dark.png")
|
||||
logo_light String? @default("/assets/logo_light.png")
|
||||
favicon String? @default("/assets/logo_square.svg")
|
||||
id String @id
|
||||
server_url String @default("http://localhost:3001")
|
||||
server_protocol String @default("http")
|
||||
server_host String @default("localhost")
|
||||
server_port Int @default(3001)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime
|
||||
update_interval Int @default(60)
|
||||
auto_update Boolean @default(false)
|
||||
github_repo_url String @default("https://github.com/PatchMon/PatchMon.git")
|
||||
ssh_key_path String?
|
||||
repository_type String @default("public")
|
||||
last_update_check DateTime?
|
||||
latest_version String?
|
||||
update_available Boolean @default(false)
|
||||
signup_enabled Boolean @default(false)
|
||||
default_user_role String @default("user")
|
||||
ignore_ssl_self_signed Boolean @default(false)
|
||||
logo_dark String? @default("/assets/logo_dark.png")
|
||||
logo_light String? @default("/assets/logo_light.png")
|
||||
favicon String? @default("/assets/logo_square.svg")
|
||||
metrics_enabled Boolean @default(true)
|
||||
metrics_anonymous_id String?
|
||||
metrics_last_sent DateTime?
|
||||
color_theme String @default("default")
|
||||
}
|
||||
|
||||
model update_history {
|
||||
@@ -324,3 +341,27 @@ model docker_image_updates {
|
||||
@@index([image_id])
|
||||
@@index([is_security_update])
|
||||
}
|
||||
|
||||
model job_history {
|
||||
id String @id
|
||||
job_id String
|
||||
queue_name String
|
||||
job_name String
|
||||
host_id String?
|
||||
api_id String?
|
||||
status String
|
||||
attempt_number Int @default(1)
|
||||
error_message String?
|
||||
output Json?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime
|
||||
completed_at DateTime?
|
||||
hosts hosts? @relation(fields: [host_id], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([job_id])
|
||||
@@index([queue_name])
|
||||
@@index([host_id])
|
||||
@@index([api_id])
|
||||
@@index([status])
|
||||
@@index([created_at])
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Database configuration for multiple instances
|
||||
* Optimizes connection pooling to prevent "too many connections" errors
|
||||
* Centralized Prisma Client Singleton
|
||||
* Prevents multiple Prisma clients from creating connection leaks
|
||||
*/
|
||||
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
@@ -16,32 +16,69 @@ function getOptimizedDatabaseUrl() {
|
||||
// Parse the URL
|
||||
const url = new URL(originalUrl);
|
||||
|
||||
// Add connection pooling parameters for multiple instances
|
||||
url.searchParams.set("connection_limit", "5"); // Reduced from default 10
|
||||
url.searchParams.set("pool_timeout", "10"); // 10 seconds
|
||||
url.searchParams.set("connect_timeout", "10"); // 10 seconds
|
||||
url.searchParams.set("idle_timeout", "300"); // 5 minutes
|
||||
url.searchParams.set("max_lifetime", "1800"); // 30 minutes
|
||||
// Add connection pooling parameters - configurable via environment variables
|
||||
const connectionLimit = process.env.DB_CONNECTION_LIMIT || "30";
|
||||
const poolTimeout = process.env.DB_POOL_TIMEOUT || "20";
|
||||
const connectTimeout = process.env.DB_CONNECT_TIMEOUT || "10";
|
||||
const idleTimeout = process.env.DB_IDLE_TIMEOUT || "300";
|
||||
const maxLifetime = process.env.DB_MAX_LIFETIME || "1800";
|
||||
|
||||
url.searchParams.set("connection_limit", connectionLimit);
|
||||
url.searchParams.set("pool_timeout", poolTimeout);
|
||||
url.searchParams.set("connect_timeout", connectTimeout);
|
||||
url.searchParams.set("idle_timeout", idleTimeout);
|
||||
url.searchParams.set("max_lifetime", maxLifetime);
|
||||
|
||||
// Log connection pool settings in development/debug mode
|
||||
if (
|
||||
process.env.ENABLE_LOGGING === "true" ||
|
||||
process.env.LOG_LEVEL === "debug"
|
||||
) {
|
||||
console.log(
|
||||
`[Database Pool] connection_limit=${connectionLimit}, pool_timeout=${poolTimeout}s, connect_timeout=${connectTimeout}s`,
|
||||
);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
// Create optimized Prisma client
|
||||
function createPrismaClient() {
|
||||
const optimizedUrl = getOptimizedDatabaseUrl();
|
||||
// Singleton Prisma client instance
|
||||
let prismaInstance = null;
|
||||
|
||||
return new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: optimizedUrl,
|
||||
function getPrismaClient() {
|
||||
if (!prismaInstance) {
|
||||
const optimizedUrl = getOptimizedDatabaseUrl();
|
||||
|
||||
prismaInstance = new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: optimizedUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
log:
|
||||
process.env.PRISMA_LOG_QUERIES === "true"
|
||||
? ["query", "info", "warn", "error"]
|
||||
: ["warn", "error"],
|
||||
errorFormat: "pretty",
|
||||
});
|
||||
log:
|
||||
process.env.PRISMA_LOG_QUERIES === "true"
|
||||
? ["query", "info", "warn", "error"]
|
||||
: ["warn", "error"],
|
||||
errorFormat: "pretty",
|
||||
});
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on("beforeExit", async () => {
|
||||
await prismaInstance.$disconnect();
|
||||
});
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
await prismaInstance.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
await prismaInstance.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
return prismaInstance;
|
||||
}
|
||||
|
||||
// Connection health check
|
||||
@@ -50,7 +87,7 @@ async function checkDatabaseConnection(prisma) {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Database connection failed:", error.message);
|
||||
console.error("Database connection check failed:", error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -121,9 +158,8 @@ async function disconnectPrisma(prisma, maxRetries = 3) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createPrismaClient,
|
||||
getPrismaClient,
|
||||
checkDatabaseConnection,
|
||||
waitForDatabase,
|
||||
disconnectPrisma,
|
||||
getOptimizedDatabaseUrl,
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
const jwt = require("jsonwebtoken");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
const {
|
||||
validate_session,
|
||||
update_session_activity,
|
||||
is_tfa_bypassed,
|
||||
} = require("../utils/session_manager");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Middleware to verify JWT token with session validation
|
||||
const authenticateToken = async (req, res, next) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const prisma = new PrismaClient();
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Permission middleware factory
|
||||
const requirePermission = (permission) => {
|
||||
|
||||
419
backend/src/routes/agentVersionRoutes.js
Normal file
419
backend/src/routes/agentVersionRoutes.js
Normal file
@@ -0,0 +1,419 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const agentVersionService = require("../services/agentVersionService");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const { requirePermission } = require("../middleware/permissions");
|
||||
|
||||
// Test GitHub API connectivity
|
||||
router.get(
|
||||
"/test-github",
|
||||
authenticateToken,
|
||||
requirePermission("can_manage_settings"),
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const axios = require("axios");
|
||||
const response = await axios.get(
|
||||
"https://api.github.com/repos/PatchMon/PatchMon-agent/releases",
|
||||
{
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
"User-Agent": "PatchMon-Server/1.0",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
status: response.status,
|
||||
releasesFound: response.data.length,
|
||||
latestRelease: response.data[0]?.tag_name || "No releases",
|
||||
rateLimitRemaining: response.headers["x-ratelimit-remaining"],
|
||||
rateLimitLimit: response.headers["x-ratelimit-limit"],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ GitHub API test failed:", error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
rateLimitRemaining: error.response?.headers["x-ratelimit-remaining"],
|
||||
rateLimitLimit: error.response?.headers["x-ratelimit-limit"],
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get current version information
|
||||
router.get("/version", authenticateToken, async (_req, res) => {
|
||||
try {
|
||||
const versionInfo = await agentVersionService.getVersionInfo();
|
||||
console.log(
|
||||
"📊 Version info response:",
|
||||
JSON.stringify(versionInfo, null, 2),
|
||||
);
|
||||
res.json(versionInfo);
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to get version info:", error.message);
|
||||
res.status(500).json({
|
||||
error: "Failed to get version information",
|
||||
details: error.message,
|
||||
status: "error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh current version by executing agent binary
|
||||
router.post(
|
||||
"/version/refresh",
|
||||
authenticateToken,
|
||||
requirePermission("can_manage_settings"),
|
||||
async (_req, res) => {
|
||||
try {
|
||||
console.log("🔄 Refreshing current agent version...");
|
||||
const currentVersion = await agentVersionService.refreshCurrentVersion();
|
||||
console.log("📊 Refreshed current version:", currentVersion);
|
||||
res.json({
|
||||
success: true,
|
||||
currentVersion: currentVersion,
|
||||
message: currentVersion
|
||||
? `Current version refreshed: ${currentVersion}`
|
||||
: "No agent binary found",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to refresh current version:", error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to refresh current version",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Download latest update
|
||||
router.post(
|
||||
"/version/download",
|
||||
authenticateToken,
|
||||
requirePermission("can_manage_settings"),
|
||||
async (_req, res) => {
|
||||
try {
|
||||
console.log("🔄 Downloading latest agent update...");
|
||||
const downloadResult = await agentVersionService.downloadLatestUpdate();
|
||||
console.log(
|
||||
"📊 Download result:",
|
||||
JSON.stringify(downloadResult, null, 2),
|
||||
);
|
||||
res.json(downloadResult);
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to download latest update:", error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to download latest update",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Check for updates
|
||||
router.post(
|
||||
"/version/check",
|
||||
authenticateToken,
|
||||
requirePermission("can_manage_settings"),
|
||||
async (_req, res) => {
|
||||
try {
|
||||
console.log("🔄 Manual update check triggered");
|
||||
const updateInfo = await agentVersionService.checkForUpdates();
|
||||
console.log(
|
||||
"📊 Update check result:",
|
||||
JSON.stringify(updateInfo, null, 2),
|
||||
);
|
||||
res.json(updateInfo);
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to check for updates:", error.message);
|
||||
res.status(500).json({ error: "Failed to check for updates" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get available versions
|
||||
router.get("/versions", authenticateToken, async (_req, res) => {
|
||||
try {
|
||||
const versions = await agentVersionService.getAvailableVersions();
|
||||
console.log(
|
||||
"📦 Available versions response:",
|
||||
JSON.stringify(versions, null, 2),
|
||||
);
|
||||
res.json({ versions });
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to get available versions:", error.message);
|
||||
res.status(500).json({ error: "Failed to get available versions" });
|
||||
}
|
||||
});
|
||||
|
||||
// Get binary information
|
||||
router.get(
|
||||
"/binary/:version/:architecture",
|
||||
authenticateToken,
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const { version, architecture } = req.params;
|
||||
const binaryInfo = await agentVersionService.getBinaryInfo(
|
||||
version,
|
||||
architecture,
|
||||
);
|
||||
res.json(binaryInfo);
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to get binary info:", error.message);
|
||||
res.status(404).json({ error: error.message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Download agent binary
|
||||
router.get(
|
||||
"/download/:version/:architecture",
|
||||
authenticateToken,
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const { version, architecture } = req.params;
|
||||
|
||||
// Validate architecture
|
||||
if (!agentVersionService.supportedArchitectures.includes(architecture)) {
|
||||
return res.status(400).json({ error: "Unsupported architecture" });
|
||||
}
|
||||
|
||||
await agentVersionService.serveBinary(version, architecture, res);
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to serve binary:", error.message);
|
||||
res.status(500).json({ error: "Failed to serve binary" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get latest binary for architecture (for agents to query)
|
||||
router.get("/latest/:architecture", async (req, res) => {
|
||||
try {
|
||||
const { architecture } = req.params;
|
||||
|
||||
// Validate architecture
|
||||
if (!agentVersionService.supportedArchitectures.includes(architecture)) {
|
||||
return res.status(400).json({ error: "Unsupported architecture" });
|
||||
}
|
||||
|
||||
const versionInfo = await agentVersionService.getVersionInfo();
|
||||
|
||||
if (!versionInfo.latestVersion) {
|
||||
return res.status(404).json({ error: "No latest version available" });
|
||||
}
|
||||
|
||||
const binaryInfo = await agentVersionService.getBinaryInfo(
|
||||
versionInfo.latestVersion,
|
||||
architecture,
|
||||
);
|
||||
|
||||
res.json({
|
||||
version: binaryInfo.version,
|
||||
architecture: binaryInfo.architecture,
|
||||
size: binaryInfo.size,
|
||||
hash: binaryInfo.hash,
|
||||
downloadUrl: `/api/v1/agent/download/${binaryInfo.version}/${binaryInfo.architecture}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to get latest binary info:", error.message);
|
||||
res.status(500).json({ error: "Failed to get latest binary information" });
|
||||
}
|
||||
});
|
||||
|
||||
// Push update notification to specific agent
|
||||
router.post(
|
||||
"/notify-update/:apiId",
|
||||
authenticateToken,
|
||||
requirePermission("admin"),
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const { apiId } = req.params;
|
||||
const { version, force = false } = req.body;
|
||||
|
||||
const versionInfo = await agentVersionService.getVersionInfo();
|
||||
const targetVersion = version || versionInfo.latestVersion;
|
||||
|
||||
if (!targetVersion) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "No version specified or available" });
|
||||
}
|
||||
|
||||
// Import WebSocket service
|
||||
const { pushUpdateNotification } = require("../services/agentWs");
|
||||
|
||||
// Push update notification via WebSocket
|
||||
pushUpdateNotification(apiId, {
|
||||
version: targetVersion,
|
||||
force,
|
||||
downloadUrl: `/api/v1/agent/latest/${req.body.architecture || "linux-amd64"}`,
|
||||
message: `Update available: ${targetVersion}`,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Update notification sent to agent ${apiId}`,
|
||||
version: targetVersion,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to notify agent update:", error.message);
|
||||
res.status(500).json({ error: "Failed to notify agent update" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Push update notification to all agents
|
||||
router.post(
|
||||
"/notify-update-all",
|
||||
authenticateToken,
|
||||
requirePermission("admin"),
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const { version, force = false } = req.body;
|
||||
|
||||
const versionInfo = await agentVersionService.getVersionInfo();
|
||||
const targetVersion = version || versionInfo.latestVersion;
|
||||
|
||||
if (!targetVersion) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "No version specified or available" });
|
||||
}
|
||||
|
||||
// Import WebSocket service
|
||||
const { pushUpdateNotificationToAll } = require("../services/agentWs");
|
||||
|
||||
// Push update notification to all connected agents
|
||||
const result = await pushUpdateNotificationToAll({
|
||||
version: targetVersion,
|
||||
force,
|
||||
message: `Update available: ${targetVersion}`,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Update notification sent to ${result.notifiedCount} agents`,
|
||||
version: targetVersion,
|
||||
notifiedCount: result.notifiedCount,
|
||||
failedCount: result.failedCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to notify all agents update:", error.message);
|
||||
res.status(500).json({ error: "Failed to notify all agents update" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Check if specific agent needs update and push notification
|
||||
router.post(
|
||||
"/check-update/:apiId",
|
||||
authenticateToken,
|
||||
requirePermission("can_manage_settings"),
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const { apiId } = req.params;
|
||||
const { version, force = false } = req.body;
|
||||
|
||||
if (!version) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Agent version is required",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`🔍 Checking update for agent ${apiId} (version: ${version})`,
|
||||
);
|
||||
const result = await agentVersionService.checkAndPushAgentUpdate(
|
||||
apiId,
|
||||
version,
|
||||
force,
|
||||
);
|
||||
console.log(
|
||||
"📊 Agent update check result:",
|
||||
JSON.stringify(result, null, 2),
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to check agent update:", error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to check agent update",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Push updates to all connected agents
|
||||
router.post(
|
||||
"/push-updates-all",
|
||||
authenticateToken,
|
||||
requirePermission("can_manage_settings"),
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const { force = false } = req.body;
|
||||
|
||||
console.log(`🔄 Pushing updates to all agents (force: ${force})`);
|
||||
const result = await agentVersionService.checkAndPushUpdatesToAll(force);
|
||||
console.log("📊 Bulk update result:", JSON.stringify(result, null, 2));
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to push updates to all agents:", error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to push updates to all agents",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Agent reports its version (for automatic update checking)
|
||||
router.post("/report-version", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { apiId, version } = req.body;
|
||||
|
||||
if (!apiId || !version) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "API ID and version are required",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`📊 Agent ${apiId} reported version: ${version}`);
|
||||
|
||||
// Check if agent needs update and push notification if needed
|
||||
const updateResult = await agentVersionService.checkAndPushAgentUpdate(
|
||||
apiId,
|
||||
version,
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Version reported successfully",
|
||||
updateCheck: updateResult,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to process agent version report:", error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to process version report",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,7 +1,7 @@
|
||||
const express = require("express");
|
||||
const bcrypt = require("bcryptjs");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
const { body, validationResult } = require("express-validator");
|
||||
const { authenticateToken, _requireAdmin } = require("../middleware/auth");
|
||||
const {
|
||||
@@ -20,7 +20,7 @@ const {
|
||||
} = require("../utils/session_manager");
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
/**
|
||||
* Parse user agent string to extract browser and OS info
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require("express");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
const crypto = require("node:crypto");
|
||||
const bcrypt = require("bcryptjs");
|
||||
const { body, validationResult } = require("express-validator");
|
||||
@@ -8,7 +8,7 @@ const { requireManageSettings } = require("../middleware/permissions");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Generate auto-enrollment token credentials
|
||||
const generate_auto_enrollment_token = () => {
|
||||
@@ -570,22 +570,25 @@ router.post(
|
||||
os_version: "unknown",
|
||||
api_id: api_id,
|
||||
api_key: api_key,
|
||||
host_group_id: req.auto_enrollment_token.default_host_group_id,
|
||||
status: "pending",
|
||||
notes: `Auto-enrolled via ${req.auto_enrollment_token.token_name} on ${new Date().toISOString()}`,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
include: {
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create host group membership if default host group is specified
|
||||
let hostGroupMembership = null;
|
||||
if (req.auto_enrollment_token.default_host_group_id) {
|
||||
hostGroupMembership = await prisma.host_group_memberships.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
host_id: host.id,
|
||||
host_group_id: req.auto_enrollment_token.default_host_group_id,
|
||||
created_at: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Update token usage stats
|
||||
await prisma.auto_enrollment_tokens.update({
|
||||
where: { id: req.auto_enrollment_token.id },
|
||||
@@ -600,6 +603,19 @@ router.post(
|
||||
`Auto-enrolled host: ${friendly_name} (${host.id}) via token: ${req.auto_enrollment_token.token_name}`,
|
||||
);
|
||||
|
||||
// Get host group details for response if membership was created
|
||||
let hostGroup = null;
|
||||
if (hostGroupMembership) {
|
||||
hostGroup = await prisma.host_groups.findUnique({
|
||||
where: { id: req.auto_enrollment_token.default_host_group_id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
message: "Host enrolled successfully",
|
||||
host: {
|
||||
@@ -607,7 +623,7 @@ router.post(
|
||||
friendly_name: host.friendly_name,
|
||||
api_id: api_id,
|
||||
api_key: api_key,
|
||||
host_group: host.host_groups,
|
||||
host_group: hostGroup,
|
||||
status: host.status,
|
||||
},
|
||||
});
|
||||
@@ -698,13 +714,24 @@ router.post(
|
||||
os_version: "unknown",
|
||||
api_id: api_id,
|
||||
api_key: api_key,
|
||||
host_group_id: req.auto_enrollment_token.default_host_group_id,
|
||||
status: "pending",
|
||||
notes: `Auto-enrolled via ${req.auto_enrollment_token.token_name} on ${new Date().toISOString()}`,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Create host group membership if default host group is specified
|
||||
if (req.auto_enrollment_token.default_host_group_id) {
|
||||
await prisma.host_group_memberships.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
host_id: host.id,
|
||||
host_group_id: req.auto_enrollment_token.default_host_group_id,
|
||||
created_at: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
results.success.push({
|
||||
id: host.id,
|
||||
friendly_name: host.friendly_name,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
const express = require("express");
|
||||
const { queueManager, QUEUE_NAMES } = require("../services/automation");
|
||||
const { getConnectedApiIds } = require("../services/agentWs");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get all queue statistics
|
||||
router.get("/stats", authenticateToken, async (req, res) => {
|
||||
router.get("/stats", authenticateToken, async (_req, res) => {
|
||||
try {
|
||||
const stats = await queueManager.getAllQueueStats();
|
||||
res.json({
|
||||
@@ -60,7 +61,10 @@ router.get("/jobs/:queueName", authenticateToken, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const jobs = await queueManager.getRecentJobs(queueName, parseInt(limit));
|
||||
const jobs = await queueManager.getRecentJobs(
|
||||
queueName,
|
||||
parseInt(limit, 10),
|
||||
);
|
||||
|
||||
// Format jobs for frontend
|
||||
const formattedJobs = jobs.map((job) => ({
|
||||
@@ -96,7 +100,7 @@ router.get("/jobs/:queueName", authenticateToken, async (req, res) => {
|
||||
});
|
||||
|
||||
// Trigger manual GitHub update check
|
||||
router.post("/trigger/github-update", authenticateToken, async (req, res) => {
|
||||
router.post("/trigger/github-update", authenticateToken, async (_req, res) => {
|
||||
try {
|
||||
const job = await queueManager.triggerGitHubUpdateCheck();
|
||||
res.json({
|
||||
@@ -116,51 +120,61 @@ router.post("/trigger/github-update", authenticateToken, async (req, res) => {
|
||||
});
|
||||
|
||||
// Trigger manual session cleanup
|
||||
router.post("/trigger/session-cleanup", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const job = await queueManager.triggerSessionCleanup();
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
jobId: job.id,
|
||||
message: "Session cleanup triggered successfully",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error triggering session cleanup:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to trigger session cleanup",
|
||||
});
|
||||
}
|
||||
});
|
||||
router.post(
|
||||
"/trigger/session-cleanup",
|
||||
authenticateToken,
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const job = await queueManager.triggerSessionCleanup();
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
jobId: job.id,
|
||||
message: "Session cleanup triggered successfully",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error triggering session cleanup:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to trigger session cleanup",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Trigger manual echo hello
|
||||
router.post("/trigger/echo-hello", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { message } = req.body;
|
||||
const job = await queueManager.triggerEchoHello(message);
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
jobId: job.id,
|
||||
message: "Echo hello triggered successfully",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error triggering echo hello:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to trigger echo hello",
|
||||
});
|
||||
}
|
||||
});
|
||||
// Trigger Agent Collection: enqueue report_now for connected agents only
|
||||
router.post(
|
||||
"/trigger/agent-collection",
|
||||
authenticateToken,
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const queue = queueManager.queues[QUEUE_NAMES.AGENT_COMMANDS];
|
||||
const apiIds = getConnectedApiIds();
|
||||
if (!apiIds || apiIds.length === 0) {
|
||||
return res.json({ success: true, data: { enqueued: 0 } });
|
||||
}
|
||||
const jobs = apiIds.map((apiId) => ({
|
||||
name: "report_now",
|
||||
data: { api_id: apiId, type: "report_now" },
|
||||
opts: { attempts: 3, backoff: { type: "fixed", delay: 2000 } },
|
||||
}));
|
||||
await queue.addBulk(jobs);
|
||||
res.json({ success: true, data: { enqueued: jobs.length } });
|
||||
} catch (error) {
|
||||
console.error("Error triggering agent collection:", error);
|
||||
res
|
||||
.status(500)
|
||||
.json({ success: false, error: "Failed to trigger agent collection" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Trigger manual orphaned repo cleanup
|
||||
router.post(
|
||||
"/trigger/orphaned-repo-cleanup",
|
||||
authenticateToken,
|
||||
async (req, res) => {
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const job = await queueManager.triggerOrphanedRepoCleanup();
|
||||
res.json({
|
||||
@@ -180,8 +194,56 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Trigger manual orphaned package cleanup
|
||||
router.post(
|
||||
"/trigger/orphaned-package-cleanup",
|
||||
authenticateToken,
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const job = await queueManager.triggerOrphanedPackageCleanup();
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
jobId: job.id,
|
||||
message: "Orphaned package cleanup triggered successfully",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error triggering orphaned package cleanup:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to trigger orphaned package cleanup",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Trigger manual Docker inventory cleanup
|
||||
router.post(
|
||||
"/trigger/docker-inventory-cleanup",
|
||||
authenticateToken,
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const job = await queueManager.triggerDockerInventoryCleanup();
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
jobId: job.id,
|
||||
message: "Docker inventory cleanup triggered successfully",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error triggering Docker inventory cleanup:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to trigger Docker inventory cleanup",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get queue health status
|
||||
router.get("/health", authenticateToken, async (req, res) => {
|
||||
router.get("/health", authenticateToken, async (_req, res) => {
|
||||
try {
|
||||
const stats = await queueManager.getAllQueueStats();
|
||||
const totalJobs = Object.values(stats).reduce((sum, queueStats) => {
|
||||
@@ -224,16 +286,20 @@ router.get("/health", authenticateToken, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get automation overview (for dashboard cards)
|
||||
router.get("/overview", authenticateToken, async (req, res) => {
|
||||
router.get("/overview", authenticateToken, async (_req, res) => {
|
||||
try {
|
||||
const stats = await queueManager.getAllQueueStats();
|
||||
const { getSettings } = require("../services/settingsService");
|
||||
const settings = await getSettings();
|
||||
|
||||
// Get recent jobs for each queue to show last run times
|
||||
const recentJobs = await Promise.all([
|
||||
queueManager.getRecentJobs(QUEUE_NAMES.GITHUB_UPDATE_CHECK, 1),
|
||||
queueManager.getRecentJobs(QUEUE_NAMES.SESSION_CLEANUP, 1),
|
||||
queueManager.getRecentJobs(QUEUE_NAMES.ECHO_HELLO, 1),
|
||||
queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_REPO_CLEANUP, 1),
|
||||
queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP, 1),
|
||||
queueManager.getRecentJobs(QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP, 1),
|
||||
queueManager.getRecentJobs(QUEUE_NAMES.AGENT_COMMANDS, 1),
|
||||
]);
|
||||
|
||||
// Calculate overview metrics
|
||||
@@ -241,23 +307,23 @@ router.get("/overview", authenticateToken, async (req, res) => {
|
||||
scheduledTasks:
|
||||
stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].delayed +
|
||||
stats[QUEUE_NAMES.SESSION_CLEANUP].delayed +
|
||||
stats[QUEUE_NAMES.SYSTEM_MAINTENANCE].delayed +
|
||||
stats[QUEUE_NAMES.ECHO_HELLO].delayed +
|
||||
stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].delayed,
|
||||
stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].delayed +
|
||||
stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].delayed +
|
||||
stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].delayed,
|
||||
|
||||
runningTasks:
|
||||
stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].active +
|
||||
stats[QUEUE_NAMES.SESSION_CLEANUP].active +
|
||||
stats[QUEUE_NAMES.SYSTEM_MAINTENANCE].active +
|
||||
stats[QUEUE_NAMES.ECHO_HELLO].active +
|
||||
stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].active,
|
||||
stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].active +
|
||||
stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].active +
|
||||
stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].active,
|
||||
|
||||
failedTasks:
|
||||
stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].failed +
|
||||
stats[QUEUE_NAMES.SESSION_CLEANUP].failed +
|
||||
stats[QUEUE_NAMES.SYSTEM_MAINTENANCE].failed +
|
||||
stats[QUEUE_NAMES.ECHO_HELLO].failed +
|
||||
stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].failed,
|
||||
stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].failed +
|
||||
stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].failed +
|
||||
stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].failed,
|
||||
|
||||
totalAutomations: Object.values(stats).reduce((sum, queueStats) => {
|
||||
return (
|
||||
@@ -305,10 +371,10 @@ router.get("/overview", authenticateToken, async (req, res) => {
|
||||
stats: stats[QUEUE_NAMES.SESSION_CLEANUP],
|
||||
},
|
||||
{
|
||||
name: "Echo Hello",
|
||||
queue: QUEUE_NAMES.ECHO_HELLO,
|
||||
description: "Simple test automation task",
|
||||
schedule: "Manual only",
|
||||
name: "Orphaned Repo Cleanup",
|
||||
queue: QUEUE_NAMES.ORPHANED_REPO_CLEANUP,
|
||||
description: "Removes repositories with no associated hosts",
|
||||
schedule: "Daily at 2 AM",
|
||||
lastRun: recentJobs[2][0]?.finishedOn
|
||||
? new Date(recentJobs[2][0].finishedOn).toLocaleString()
|
||||
: "Never",
|
||||
@@ -318,13 +384,13 @@ router.get("/overview", authenticateToken, async (req, res) => {
|
||||
: recentJobs[2][0]
|
||||
? "Success"
|
||||
: "Never run",
|
||||
stats: stats[QUEUE_NAMES.ECHO_HELLO],
|
||||
stats: stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP],
|
||||
},
|
||||
{
|
||||
name: "Orphaned Repo Cleanup",
|
||||
queue: QUEUE_NAMES.ORPHANED_REPO_CLEANUP,
|
||||
description: "Removes repositories with no associated hosts",
|
||||
schedule: "Daily at 2 AM",
|
||||
name: "Orphaned Package Cleanup",
|
||||
queue: QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP,
|
||||
description: "Removes packages with no associated hosts",
|
||||
schedule: "Daily at 3 AM",
|
||||
lastRun: recentJobs[3][0]?.finishedOn
|
||||
? new Date(recentJobs[3][0].finishedOn).toLocaleString()
|
||||
: "Never",
|
||||
@@ -334,7 +400,40 @@ router.get("/overview", authenticateToken, async (req, res) => {
|
||||
: recentJobs[3][0]
|
||||
? "Success"
|
||||
: "Never run",
|
||||
stats: stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP],
|
||||
stats: stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP],
|
||||
},
|
||||
{
|
||||
name: "Docker Inventory Cleanup",
|
||||
queue: QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP,
|
||||
description:
|
||||
"Removes Docker containers and images for non-existent hosts",
|
||||
schedule: "Daily at 4 AM",
|
||||
lastRun: recentJobs[4][0]?.finishedOn
|
||||
? new Date(recentJobs[4][0].finishedOn).toLocaleString()
|
||||
: "Never",
|
||||
lastRunTimestamp: recentJobs[4][0]?.finishedOn || 0,
|
||||
status: recentJobs[4][0]?.failedReason
|
||||
? "Failed"
|
||||
: recentJobs[4][0]
|
||||
? "Success"
|
||||
: "Never run",
|
||||
stats: stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP],
|
||||
},
|
||||
{
|
||||
name: "Collect Host Statistics",
|
||||
queue: QUEUE_NAMES.AGENT_COMMANDS,
|
||||
description: "Collects package statistics from connected agents only",
|
||||
schedule: `Every ${settings.update_interval} minutes (Agent-driven)`,
|
||||
lastRun: recentJobs[5][0]?.finishedOn
|
||||
? new Date(recentJobs[5][0].finishedOn).toLocaleString()
|
||||
: "Never",
|
||||
lastRunTimestamp: recentJobs[5][0]?.finishedOn || 0,
|
||||
status: recentJobs[5][0]?.failedReason
|
||||
? "Failed"
|
||||
: recentJobs[5][0]
|
||||
? "Success"
|
||||
: "Never run",
|
||||
stats: stats[QUEUE_NAMES.AGENT_COMMANDS],
|
||||
},
|
||||
].sort((a, b) => {
|
||||
// Sort by last run timestamp (most recent first)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
const express = require("express");
|
||||
const { body, validationResult } = require("express-validator");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Helper function to get user permissions based on role
|
||||
async function getUserPermissions(userRole) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
const express = require("express");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = getPrismaClient();
|
||||
const router = express.Router();
|
||||
|
||||
// Helper function to convert BigInt fields to strings for JSON serialization
|
||||
@@ -522,7 +522,8 @@ router.get("/updates", authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v1/docker/collect - Collect Docker data from agent
|
||||
// POST /api/v1/docker/collect - Collect Docker data from agent (DEPRECATED - kept for backward compatibility)
|
||||
// New agents should use POST /api/v1/integrations/docker
|
||||
router.post("/collect", async (req, res) => {
|
||||
try {
|
||||
const { apiId, apiKey, containers, images, updates } = req.body;
|
||||
@@ -745,6 +746,322 @@ router.post("/collect", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v1/integrations/docker - New integration endpoint for Docker data collection
|
||||
router.post("/../integrations/docker", async (req, res) => {
|
||||
try {
|
||||
const apiId = req.headers["x-api-id"];
|
||||
const apiKey = req.headers["x-api-key"];
|
||||
const {
|
||||
containers,
|
||||
images,
|
||||
updates,
|
||||
daemon_info: _daemon_info,
|
||||
hostname,
|
||||
machine_id,
|
||||
agent_version: _agent_version,
|
||||
} = req.body;
|
||||
|
||||
console.log(
|
||||
`[Docker Integration] Received data from ${hostname || machine_id}`,
|
||||
);
|
||||
|
||||
// Validate API credentials
|
||||
const host = await prisma.hosts.findFirst({
|
||||
where: { api_id: apiId, api_key: apiKey },
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
console.warn("[Docker Integration] Invalid API credentials");
|
||||
return res.status(401).json({ error: "Invalid API credentials" });
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Docker Integration] Processing for host: ${host.friendly_name}`,
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Helper function to validate and parse dates
|
||||
const parseDate = (dateString) => {
|
||||
if (!dateString) return now;
|
||||
const date = new Date(dateString);
|
||||
return Number.isNaN(date.getTime()) ? now : date;
|
||||
};
|
||||
|
||||
let containersProcessed = 0;
|
||||
let imagesProcessed = 0;
|
||||
let updatesProcessed = 0;
|
||||
|
||||
// Process containers
|
||||
if (containers && Array.isArray(containers)) {
|
||||
console.log(
|
||||
`[Docker Integration] Processing ${containers.length} containers`,
|
||||
);
|
||||
for (const containerData of containers) {
|
||||
const containerId = uuidv4();
|
||||
|
||||
// Find or create image
|
||||
let imageId = null;
|
||||
if (containerData.image_repository && containerData.image_tag) {
|
||||
const image = await prisma.docker_images.upsert({
|
||||
where: {
|
||||
repository_tag_image_id: {
|
||||
repository: containerData.image_repository,
|
||||
tag: containerData.image_tag,
|
||||
image_id: containerData.image_id || "unknown",
|
||||
},
|
||||
},
|
||||
update: {
|
||||
last_checked: now,
|
||||
updated_at: now,
|
||||
},
|
||||
create: {
|
||||
id: uuidv4(),
|
||||
repository: containerData.image_repository,
|
||||
tag: containerData.image_tag,
|
||||
image_id: containerData.image_id || "unknown",
|
||||
source: containerData.image_source || "docker-hub",
|
||||
created_at: parseDate(containerData.created_at),
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
imageId = image.id;
|
||||
}
|
||||
|
||||
// Upsert container
|
||||
await prisma.docker_containers.upsert({
|
||||
where: {
|
||||
host_id_container_id: {
|
||||
host_id: host.id,
|
||||
container_id: containerData.container_id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
name: containerData.name,
|
||||
image_id: imageId,
|
||||
image_name: containerData.image_name,
|
||||
image_tag: containerData.image_tag || "latest",
|
||||
status: containerData.status,
|
||||
state: containerData.state || containerData.status,
|
||||
ports: containerData.ports || null,
|
||||
started_at: containerData.started_at
|
||||
? parseDate(containerData.started_at)
|
||||
: null,
|
||||
updated_at: now,
|
||||
last_checked: now,
|
||||
},
|
||||
create: {
|
||||
id: containerId,
|
||||
host_id: host.id,
|
||||
container_id: containerData.container_id,
|
||||
name: containerData.name,
|
||||
image_id: imageId,
|
||||
image_name: containerData.image_name,
|
||||
image_tag: containerData.image_tag || "latest",
|
||||
status: containerData.status,
|
||||
state: containerData.state || containerData.status,
|
||||
ports: containerData.ports || null,
|
||||
created_at: parseDate(containerData.created_at),
|
||||
started_at: containerData.started_at
|
||||
? parseDate(containerData.started_at)
|
||||
: null,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
containersProcessed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Process standalone images
|
||||
if (images && Array.isArray(images)) {
|
||||
console.log(`[Docker Integration] Processing ${images.length} images`);
|
||||
for (const imageData of images) {
|
||||
await prisma.docker_images.upsert({
|
||||
where: {
|
||||
repository_tag_image_id: {
|
||||
repository: imageData.repository,
|
||||
tag: imageData.tag,
|
||||
image_id: imageData.image_id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
size_bytes: imageData.size_bytes
|
||||
? BigInt(imageData.size_bytes)
|
||||
: null,
|
||||
digest: imageData.digest || null,
|
||||
last_checked: now,
|
||||
updated_at: now,
|
||||
},
|
||||
create: {
|
||||
id: uuidv4(),
|
||||
repository: imageData.repository,
|
||||
tag: imageData.tag,
|
||||
image_id: imageData.image_id,
|
||||
digest: imageData.digest,
|
||||
size_bytes: imageData.size_bytes
|
||||
? BigInt(imageData.size_bytes)
|
||||
: null,
|
||||
source: imageData.source || "docker-hub",
|
||||
created_at: parseDate(imageData.created_at),
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
imagesProcessed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Process updates
|
||||
if (updates && Array.isArray(updates)) {
|
||||
console.log(`[Docker Integration] Processing ${updates.length} updates`);
|
||||
for (const updateData of updates) {
|
||||
// Find the image by repository and image_id
|
||||
const image = await prisma.docker_images.findFirst({
|
||||
where: {
|
||||
repository: updateData.repository,
|
||||
tag: updateData.current_tag,
|
||||
image_id: updateData.image_id,
|
||||
},
|
||||
});
|
||||
|
||||
if (image) {
|
||||
// Store digest info in changelog_url field as JSON
|
||||
const digestInfo = JSON.stringify({
|
||||
method: "digest_comparison",
|
||||
current_digest: updateData.current_digest,
|
||||
available_digest: updateData.available_digest,
|
||||
});
|
||||
|
||||
// Upsert the update record
|
||||
await prisma.docker_image_updates.upsert({
|
||||
where: {
|
||||
image_id_available_tag: {
|
||||
image_id: image.id,
|
||||
available_tag: updateData.available_tag,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
updated_at: now,
|
||||
changelog_url: digestInfo,
|
||||
severity: "digest_changed",
|
||||
},
|
||||
create: {
|
||||
id: uuidv4(),
|
||||
image_id: image.id,
|
||||
current_tag: updateData.current_tag,
|
||||
available_tag: updateData.available_tag,
|
||||
severity: "digest_changed",
|
||||
changelog_url: digestInfo,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
updatesProcessed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Docker Integration] Successfully processed: ${containersProcessed} containers, ${imagesProcessed} images, ${updatesProcessed} updates`,
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: "Docker data collected successfully",
|
||||
containers_received: containersProcessed,
|
||||
images_received: imagesProcessed,
|
||||
updates_found: updatesProcessed,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Docker Integration] Error collecting Docker data:", error);
|
||||
console.error("[Docker Integration] Error stack:", error.stack);
|
||||
res.status(500).json({
|
||||
error: "Failed to collect Docker data",
|
||||
message: error.message,
|
||||
details: process.env.NODE_ENV === "development" ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/v1/docker/containers/:id - Delete a container
|
||||
router.delete("/containers/:id", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if container exists
|
||||
const container = await prisma.docker_containers.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!container) {
|
||||
return res.status(404).json({ error: "Container not found" });
|
||||
}
|
||||
|
||||
// Delete the container
|
||||
await prisma.docker_containers.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
console.log(`🗑️ Deleted container: ${container.name} (${id})`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Container ${container.name} deleted successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error deleting container:", error);
|
||||
res.status(500).json({ error: "Failed to delete container" });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/v1/docker/images/:id - Delete an image
|
||||
router.delete("/images/:id", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if image exists
|
||||
const image = await prisma.docker_images.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
docker_containers: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
return res.status(404).json({ error: "Image not found" });
|
||||
}
|
||||
|
||||
// Check if image is in use by containers
|
||||
if (image._count.docker_containers > 0) {
|
||||
return res.status(400).json({
|
||||
error: `Cannot delete image: ${image._count.docker_containers} container(s) are using this image`,
|
||||
containersCount: image._count.docker_containers,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete image updates first
|
||||
await prisma.docker_image_updates.deleteMany({
|
||||
where: { image_id: id },
|
||||
});
|
||||
|
||||
// Delete the image
|
||||
await prisma.docker_images.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
console.log(`🗑️ Deleted image: ${image.repository}:${image.tag} (${id})`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Image ${image.repository}:${image.tag} deleted successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error deleting image:", error);
|
||||
res.status(500).json({ error: "Failed to delete image" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/v1/docker/agent - Serve the Docker agent installation script
|
||||
router.get("/agent", async (_req, res) => {
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
const express = require("express");
|
||||
const { createPrismaClient } = require("../config/database");
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
const bcrypt = require("bcryptjs");
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = createPrismaClient();
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Middleware to authenticate API key
|
||||
const authenticateApiKey = async (req, res, next) => {
|
||||
@@ -114,9 +114,15 @@ router.get("/stats", authenticateApiKey, async (_req, res) => {
|
||||
where: { status: "active" },
|
||||
});
|
||||
|
||||
// Get total outdated packages count
|
||||
const totalOutdatedPackages = await prisma.host_packages.count({
|
||||
where: { needs_update: true },
|
||||
// Get total unique packages that need updates (consistent with dashboard)
|
||||
const totalOutdatedPackages = await prisma.packages.count({
|
||||
where: {
|
||||
host_packages: {
|
||||
some: {
|
||||
needs_update: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Get total repositories count
|
||||
@@ -136,11 +142,15 @@ router.get("/stats", authenticateApiKey, async (_req, res) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Get security updates count
|
||||
const securityUpdates = await prisma.host_packages.count({
|
||||
// Get security updates count (unique packages - consistent with dashboard)
|
||||
const securityUpdates = await prisma.packages.count({
|
||||
where: {
|
||||
needs_update: true,
|
||||
is_security_update: true,
|
||||
host_packages: {
|
||||
some: {
|
||||
needs_update: true,
|
||||
is_security_update: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
const express = require("express");
|
||||
const { body, validationResult } = require("express-validator");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const { requireManageHosts } = require("../middleware/permissions");
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Get all host groups
|
||||
router.get("/", authenticateToken, async (_req, res) => {
|
||||
@@ -15,7 +15,7 @@ router.get("/", authenticateToken, async (_req, res) => {
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
hosts: true,
|
||||
host_group_memberships: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -39,16 +39,20 @@ router.get("/:id", authenticateToken, async (req, res) => {
|
||||
const hostGroup = await prisma.host_groups.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
hosts: {
|
||||
select: {
|
||||
id: true,
|
||||
friendly_name: true,
|
||||
hostname: true,
|
||||
ip: true,
|
||||
os_type: true,
|
||||
os_version: true,
|
||||
status: true,
|
||||
last_update: true,
|
||||
host_group_memberships: {
|
||||
include: {
|
||||
hosts: {
|
||||
select: {
|
||||
id: true,
|
||||
friendly_name: true,
|
||||
hostname: true,
|
||||
ip: true,
|
||||
os_type: true,
|
||||
os_version: true,
|
||||
status: true,
|
||||
last_update: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -195,7 +199,7 @@ router.delete(
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
hosts: true,
|
||||
host_group_memberships: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -205,11 +209,10 @@ router.delete(
|
||||
return res.status(404).json({ error: "Host group not found" });
|
||||
}
|
||||
|
||||
// If host group has hosts, ungroup them first
|
||||
if (existingGroup._count.hosts > 0) {
|
||||
await prisma.hosts.updateMany({
|
||||
// If host group has memberships, remove them first
|
||||
if (existingGroup._count.host_group_memberships > 0) {
|
||||
await prisma.host_group_memberships.deleteMany({
|
||||
where: { host_group_id: id },
|
||||
data: { host_group_id: null },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -231,7 +234,13 @@ router.get("/:id/hosts", authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const hosts = await prisma.hosts.findMany({
|
||||
where: { host_group_id: id },
|
||||
where: {
|
||||
host_group_memberships: {
|
||||
some: {
|
||||
host_group_id: id,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
friendly_name: true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require("express");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
const { body, validationResult } = require("express-validator");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
const crypto = require("node:crypto");
|
||||
@@ -12,9 +12,9 @@ const {
|
||||
} = require("../middleware/permissions");
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Secure endpoint to download the agent script (requires API authentication)
|
||||
// Secure endpoint to download the agent script/binary (requires API authentication)
|
||||
router.get("/agent/download", async (req, res) => {
|
||||
try {
|
||||
// Verify API credentials
|
||||
@@ -34,79 +34,186 @@ router.get("/agent/download", async (req, res) => {
|
||||
return res.status(401).json({ error: "Invalid API credentials" });
|
||||
}
|
||||
|
||||
// Serve agent script directly from file system
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const agentPath = path.join(__dirname, "../../../agents/patchmon-agent.sh");
|
||||
// Check if this is a legacy agent (bash script) requesting update
|
||||
// Legacy agents will have agent_version < 1.2.9 (excluding 1.2.9 itself)
|
||||
// But allow forcing binary download for fresh installations
|
||||
const forceBinary = req.query.force === "binary";
|
||||
const isLegacyAgent =
|
||||
!forceBinary &&
|
||||
host.agent_version &&
|
||||
((host.agent_version.startsWith("1.2.") &&
|
||||
host.agent_version !== "1.2.9") ||
|
||||
host.agent_version.startsWith("1.1.") ||
|
||||
host.agent_version.startsWith("1.0."));
|
||||
|
||||
if (!fs.existsSync(agentPath)) {
|
||||
return res.status(404).json({ error: "Agent script not found" });
|
||||
}
|
||||
if (isLegacyAgent) {
|
||||
// Serve migration script for legacy agents
|
||||
const migrationScriptPath = path.join(
|
||||
__dirname,
|
||||
"../../../agents/patchmon-agent.sh",
|
||||
);
|
||||
|
||||
// Read file and convert line endings
|
||||
let scriptContent = fs
|
||||
.readFileSync(agentPath, "utf8")
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
|
||||
// Determine curl flags dynamically from settings for consistency
|
||||
let curlFlags = "-s";
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
if (settings && settings.ignore_ssl_self_signed === true) {
|
||||
curlFlags = "-sk";
|
||||
if (!fs.existsSync(migrationScriptPath)) {
|
||||
return res.status(404).json({ error: "Migration script not found" });
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Inject the curl flags into the script
|
||||
scriptContent = scriptContent.replace(
|
||||
'CURL_FLAGS=""',
|
||||
`CURL_FLAGS="${curlFlags}"`,
|
||||
);
|
||||
// Set appropriate headers for script download
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
'attachment; filename="patchmon-agent.sh"',
|
||||
);
|
||||
|
||||
res.setHeader("Content-Type", "application/x-shellscript");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
'attachment; filename="patchmon-agent.sh"',
|
||||
);
|
||||
res.send(scriptContent);
|
||||
// Stream the migration script
|
||||
const fileStream = fs.createReadStream(migrationScriptPath);
|
||||
fileStream.pipe(res);
|
||||
|
||||
fileStream.on("error", (error) => {
|
||||
console.error("Migration script stream error:", error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Failed to stream migration script" });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Serve Go binary for new agents
|
||||
const architecture = req.query.arch || "amd64";
|
||||
|
||||
// Validate architecture
|
||||
const validArchitectures = ["amd64", "386", "arm64", "arm"];
|
||||
if (!validArchitectures.includes(architecture)) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid architecture. Must be one of: amd64, 386, arm64, arm",
|
||||
});
|
||||
}
|
||||
|
||||
const binaryName = `patchmon-agent-linux-${architecture}`;
|
||||
const binaryPath = path.join(__dirname, "../../../agents", binaryName);
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
return res.status(404).json({
|
||||
error: `Agent binary not found for architecture: ${architecture}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Set appropriate headers for binary download
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${binaryName}"`,
|
||||
);
|
||||
|
||||
// Stream the binary file
|
||||
const fileStream = fs.createReadStream(binaryPath);
|
||||
fileStream.pipe(res);
|
||||
|
||||
fileStream.on("error", (error) => {
|
||||
console.error("Binary stream error:", error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Failed to stream agent binary" });
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Agent download error:", error);
|
||||
res.status(500).json({ error: "Failed to download agent script" });
|
||||
res.status(500).json({ error: "Failed to serve agent" });
|
||||
}
|
||||
});
|
||||
|
||||
// Version check endpoint for agents
|
||||
router.get("/agent/version", async (_req, res) => {
|
||||
router.get("/agent/version", async (req, res) => {
|
||||
try {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { exec } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Read version directly from agent script file
|
||||
const agentPath = path.join(__dirname, "../../../agents/patchmon-agent.sh");
|
||||
// Get architecture parameter (default to amd64 for Go agents)
|
||||
const architecture = req.query.arch || "amd64";
|
||||
const agentType = req.query.type || "go"; // "go" or "legacy"
|
||||
|
||||
if (!fs.existsSync(agentPath)) {
|
||||
return res.status(404).json({ error: "Agent script not found" });
|
||||
if (agentType === "legacy") {
|
||||
// Legacy agent version check (bash script)
|
||||
const agentPath = path.join(
|
||||
__dirname,
|
||||
"../../../agents/patchmon-agent.sh",
|
||||
);
|
||||
|
||||
if (!fs.existsSync(agentPath)) {
|
||||
return res.status(404).json({ error: "Legacy agent script not found" });
|
||||
}
|
||||
|
||||
const scriptContent = fs.readFileSync(agentPath, "utf8");
|
||||
const versionMatch = scriptContent.match(/AGENT_VERSION="([^"]+)"/);
|
||||
|
||||
if (!versionMatch) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Could not extract version from agent script" });
|
||||
}
|
||||
|
||||
const currentVersion = versionMatch[1];
|
||||
|
||||
res.json({
|
||||
currentVersion: currentVersion,
|
||||
downloadUrl: `/api/v1/hosts/agent/download`,
|
||||
releaseNotes: `PatchMon Agent v${currentVersion}`,
|
||||
minServerVersion: null,
|
||||
});
|
||||
} else {
|
||||
// Go agent version check (binary)
|
||||
const binaryName = `patchmon-agent-linux-${architecture}`;
|
||||
const binaryPath = path.join(__dirname, "../../../agents", binaryName);
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
return res.status(404).json({
|
||||
error: `Go agent binary not found for architecture: ${architecture}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Execute the binary to get its version
|
||||
try {
|
||||
const { stdout } = await execAsync(`${binaryPath} --help`, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Parse version from help output (e.g., "PatchMon Agent v1.3.1")
|
||||
const versionMatch = stdout.match(
|
||||
/PatchMon Agent v([0-9]+\.[0-9]+\.[0-9]+)/i,
|
||||
);
|
||||
|
||||
if (!versionMatch) {
|
||||
return res.status(500).json({
|
||||
error: "Could not extract version from agent binary",
|
||||
});
|
||||
}
|
||||
|
||||
const serverVersion = versionMatch[1];
|
||||
const agentVersion = req.query.currentVersion || serverVersion;
|
||||
|
||||
// Simple version comparison (assuming semantic versioning)
|
||||
const hasUpdate = agentVersion !== serverVersion;
|
||||
|
||||
res.json({
|
||||
currentVersion: agentVersion,
|
||||
latestVersion: serverVersion,
|
||||
hasUpdate: hasUpdate,
|
||||
downloadUrl: `/api/v1/hosts/agent/download?arch=${architecture}`,
|
||||
releaseNotes: `PatchMon Agent v${serverVersion}`,
|
||||
minServerVersion: null,
|
||||
architecture: architecture,
|
||||
agentType: "go",
|
||||
});
|
||||
} catch (execError) {
|
||||
console.error("Failed to execute agent binary:", execError.message);
|
||||
return res.status(500).json({
|
||||
error: "Failed to get version from agent binary",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const scriptContent = fs.readFileSync(agentPath, "utf8");
|
||||
const versionMatch = scriptContent.match(/AGENT_VERSION="([^"]+)"/);
|
||||
|
||||
if (!versionMatch) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Could not extract version from agent script" });
|
||||
}
|
||||
|
||||
const currentVersion = versionMatch[1];
|
||||
|
||||
res.json({
|
||||
currentVersion: currentVersion,
|
||||
downloadUrl: `/api/v1/hosts/agent/download`,
|
||||
releaseNotes: `PatchMon Agent v${currentVersion}`,
|
||||
minServerVersion: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Version check error:", error);
|
||||
res.status(500).json({ error: "Failed to get agent version" });
|
||||
@@ -158,7 +265,14 @@ router.post(
|
||||
body("friendly_name")
|
||||
.isLength({ min: 1 })
|
||||
.withMessage("Friendly name is required"),
|
||||
body("hostGroupId").optional(),
|
||||
body("hostGroupIds")
|
||||
.optional()
|
||||
.isArray()
|
||||
.withMessage("Host group IDs must be an array"),
|
||||
body("hostGroupIds.*")
|
||||
.optional()
|
||||
.isUUID()
|
||||
.withMessage("Each host group ID must be a valid UUID"),
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
@@ -167,19 +281,21 @@ router.post(
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { friendly_name, hostGroupId } = req.body;
|
||||
const { friendly_name, hostGroupIds } = req.body;
|
||||
|
||||
// Generate unique API credentials for this host
|
||||
const { apiId, apiKey } = generateApiCredentials();
|
||||
|
||||
// If hostGroupId is provided, verify the group exists
|
||||
if (hostGroupId) {
|
||||
const hostGroup = await prisma.host_groups.findUnique({
|
||||
where: { id: hostGroupId },
|
||||
// If hostGroupIds is provided, verify all groups exist
|
||||
if (hostGroupIds && hostGroupIds.length > 0) {
|
||||
const hostGroups = await prisma.host_groups.findMany({
|
||||
where: { id: { in: hostGroupIds } },
|
||||
});
|
||||
|
||||
if (!hostGroup) {
|
||||
return res.status(400).json({ error: "Host group not found" });
|
||||
if (hostGroups.length !== hostGroupIds.length) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "One or more host groups not found" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,16 +311,31 @@ router.post(
|
||||
architecture: null, // Will be updated when agent connects
|
||||
api_id: apiId,
|
||||
api_key: apiKey,
|
||||
host_group_id: hostGroupId || null,
|
||||
status: "pending", // Will change to 'active' when agent connects
|
||||
updated_at: new Date(),
|
||||
// Create host group memberships if hostGroupIds are provided
|
||||
host_group_memberships:
|
||||
hostGroupIds && hostGroupIds.length > 0
|
||||
? {
|
||||
create: hostGroupIds.map((groupId) => ({
|
||||
id: uuidv4(),
|
||||
host_groups: {
|
||||
connect: { id: groupId },
|
||||
},
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: {
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
host_group_memberships: {
|
||||
include: {
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -216,12 +347,35 @@ router.post(
|
||||
friendlyName: host.friendly_name,
|
||||
apiId: host.api_id,
|
||||
apiKey: host.api_key,
|
||||
hostGroup: host.host_groups,
|
||||
hostGroups:
|
||||
host.host_group_memberships?.map(
|
||||
(membership) => membership.host_groups,
|
||||
) || [],
|
||||
instructions:
|
||||
"Use these credentials in your patchmon agent configuration. System information will be automatically detected when the agent connects.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Host creation error:", error);
|
||||
|
||||
// Check if error is related to connection pool exhaustion
|
||||
if (
|
||||
error.message &&
|
||||
(error.message.includes("connection pool") ||
|
||||
error.message.includes("Timed out fetching") ||
|
||||
error.message.includes("pool timeout"))
|
||||
) {
|
||||
console.error("⚠️ DATABASE CONNECTION POOL EXHAUSTED!");
|
||||
console.error(
|
||||
`⚠️ Current limit: DB_CONNECTION_LIMIT=${process.env.DB_CONNECTION_LIMIT || "30"}`,
|
||||
);
|
||||
console.error(
|
||||
`⚠️ Pool timeout: DB_POOL_TIMEOUT=${process.env.DB_POOL_TIMEOUT || "20"}s`,
|
||||
);
|
||||
console.error(
|
||||
"⚠️ Suggestion: Increase DB_CONNECTION_LIMIT in your .env file",
|
||||
);
|
||||
}
|
||||
|
||||
res.status(500).json({ error: "Failed to create host" });
|
||||
}
|
||||
},
|
||||
@@ -406,7 +560,7 @@ router.post(
|
||||
// Process packages in batches using createMany/updateMany
|
||||
const packagesToCreate = [];
|
||||
const packagesToUpdate = [];
|
||||
const hostPackagesToUpsert = [];
|
||||
const _hostPackagesToUpsert = [];
|
||||
|
||||
// First pass: identify what needs to be created/updated
|
||||
const existingPackages = await tx.packages.findMany({
|
||||
@@ -652,19 +806,41 @@ router.get("/info", validateApiCredentials, async (req, res) => {
|
||||
// Ping endpoint for health checks (now uses API credentials)
|
||||
router.post("/ping", validateApiCredentials, async (req, res) => {
|
||||
try {
|
||||
// Update last update timestamp
|
||||
const now = new Date();
|
||||
const lastUpdate = req.hostRecord.last_update;
|
||||
|
||||
// Detect if this is an agent startup (first ping or after long absence)
|
||||
const timeSinceLastUpdate = lastUpdate ? now - lastUpdate : null;
|
||||
const isStartup =
|
||||
!timeSinceLastUpdate || timeSinceLastUpdate > 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// Log agent startup
|
||||
if (isStartup) {
|
||||
console.log(
|
||||
`🚀 Agent startup detected: ${req.hostRecord.friendly_name} (${req.hostRecord.hostname || req.hostRecord.api_id})`,
|
||||
);
|
||||
|
||||
// Check if status was previously offline
|
||||
if (req.hostRecord.status === "offline") {
|
||||
console.log(`✅ Agent back online: ${req.hostRecord.friendly_name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update last update timestamp and set status to active
|
||||
await prisma.hosts.update({
|
||||
where: { id: req.hostRecord.id },
|
||||
data: {
|
||||
last_update: new Date(),
|
||||
updated_at: new Date(),
|
||||
last_update: now,
|
||||
updated_at: now,
|
||||
status: "active",
|
||||
},
|
||||
});
|
||||
|
||||
const response = {
|
||||
message: "Ping successful",
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp: now.toISOString(),
|
||||
friendlyName: req.hostRecord.friendly_name,
|
||||
agentStartup: isStartup,
|
||||
};
|
||||
|
||||
// Check if this is a crontab update trigger
|
||||
@@ -732,9 +908,8 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Admin endpoint to bulk update host groups
|
||||
router.put(
|
||||
"/bulk/group",
|
||||
"/bulk/groups",
|
||||
authenticateToken,
|
||||
requireManageHosts,
|
||||
[
|
||||
@@ -742,7 +917,11 @@ router.put(
|
||||
body("hostIds.*")
|
||||
.isLength({ min: 1 })
|
||||
.withMessage("Each host ID must be provided"),
|
||||
body("hostGroupId").optional(),
|
||||
body("groupIds").isArray().optional(),
|
||||
body("groupIds.*")
|
||||
.optional()
|
||||
.isUUID()
|
||||
.withMessage("Each group ID must be a valid UUID"),
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
@@ -751,16 +930,21 @@ router.put(
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { hostIds, hostGroupId } = req.body;
|
||||
const { hostIds, groupIds = [] } = req.body;
|
||||
|
||||
// If hostGroupId is provided, verify the group exists
|
||||
if (hostGroupId) {
|
||||
const hostGroup = await prisma.host_groups.findUnique({
|
||||
where: { id: hostGroupId },
|
||||
// Verify all groups exist if provided
|
||||
if (groupIds.length > 0) {
|
||||
const existingGroups = await prisma.host_groups.findMany({
|
||||
where: { id: { in: groupIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!hostGroup) {
|
||||
return res.status(400).json({ error: "Host group not found" });
|
||||
if (existingGroups.length !== groupIds.length) {
|
||||
return res.status(400).json({
|
||||
error: "One or more host groups not found",
|
||||
provided: groupIds,
|
||||
found: existingGroups.map((g) => g.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -779,44 +963,153 @@ router.put(
|
||||
});
|
||||
}
|
||||
|
||||
// Bulk update host groups
|
||||
const updateResult = await prisma.hosts.updateMany({
|
||||
where: { id: { in: hostIds } },
|
||||
data: {
|
||||
host_group_id: hostGroupId || null,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
});
|
||||
// Use transaction to update group memberships for all hosts
|
||||
const updatedHosts = await prisma.$transaction(async (tx) => {
|
||||
const results = [];
|
||||
|
||||
// Get updated hosts with group information
|
||||
const updatedHosts = await prisma.hosts.findMany({
|
||||
where: { id: { in: hostIds } },
|
||||
select: {
|
||||
id: true,
|
||||
friendly_name: true,
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
for (const hostId of hostIds) {
|
||||
// Remove existing memberships for this host
|
||||
await tx.host_group_memberships.deleteMany({
|
||||
where: { host_id: hostId },
|
||||
});
|
||||
|
||||
// Add new memberships for this host
|
||||
if (groupIds.length > 0) {
|
||||
await tx.host_group_memberships.createMany({
|
||||
data: groupIds.map((groupId) => ({
|
||||
id: crypto.randomUUID(),
|
||||
host_id: hostId,
|
||||
host_group_id: groupId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Get updated host with groups
|
||||
const updatedHost = await tx.hosts.findUnique({
|
||||
where: { id: hostId },
|
||||
include: {
|
||||
host_group_memberships: {
|
||||
include: {
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
results.push(updatedHost);
|
||||
}
|
||||
|
||||
return results;
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `Successfully updated ${updateResult.count} host${updateResult.count !== 1 ? "s" : ""}`,
|
||||
updatedCount: updateResult.count,
|
||||
message: `Successfully updated ${updatedHosts.length} host${updatedHosts.length !== 1 ? "s" : ""}`,
|
||||
updatedCount: updatedHosts.length,
|
||||
hosts: updatedHosts,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Bulk host group update error:", error);
|
||||
console.error("Bulk host groups update error:", error);
|
||||
res.status(500).json({ error: "Failed to update host groups" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Admin endpoint to update host group
|
||||
// Admin endpoint to update host groups (many-to-many)
|
||||
router.put(
|
||||
"/:hostId/groups",
|
||||
authenticateToken,
|
||||
requireManageHosts,
|
||||
[body("groupIds").isArray().optional()],
|
||||
async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { hostId } = req.params;
|
||||
const { groupIds = [] } = req.body;
|
||||
|
||||
// Check if host exists
|
||||
const host = await prisma.hosts.findUnique({
|
||||
where: { id: hostId },
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
// Verify all groups exist
|
||||
if (groupIds.length > 0) {
|
||||
const existingGroups = await prisma.host_groups.findMany({
|
||||
where: { id: { in: groupIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existingGroups.length !== groupIds.length) {
|
||||
return res.status(400).json({
|
||||
error: "One or more host groups not found",
|
||||
provided: groupIds,
|
||||
found: existingGroups.map((g) => g.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Use transaction to update group memberships
|
||||
const updatedHost = await prisma.$transaction(async (tx) => {
|
||||
// Remove existing memberships
|
||||
await tx.host_group_memberships.deleteMany({
|
||||
where: { host_id: hostId },
|
||||
});
|
||||
|
||||
// Add new memberships
|
||||
if (groupIds.length > 0) {
|
||||
await tx.host_group_memberships.createMany({
|
||||
data: groupIds.map((groupId) => ({
|
||||
id: crypto.randomUUID(),
|
||||
host_id: hostId,
|
||||
host_group_id: groupId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Return updated host with groups
|
||||
return await tx.hosts.findUnique({
|
||||
where: { id: hostId },
|
||||
include: {
|
||||
host_group_memberships: {
|
||||
include: {
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: "Host groups updated successfully",
|
||||
host: updatedHost,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Host groups update error:", error);
|
||||
res.status(500).json({ error: "Failed to update host groups" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Legacy endpoint to update single host group (for backward compatibility)
|
||||
router.put(
|
||||
"/:hostId/group",
|
||||
authenticateToken,
|
||||
@@ -832,6 +1125,9 @@ router.put(
|
||||
const { hostId } = req.params;
|
||||
const { hostGroupId } = req.body;
|
||||
|
||||
// Convert single group to array and use the new endpoint logic
|
||||
const _groupIds = hostGroupId ? [hostGroupId] : [];
|
||||
|
||||
// Check if host exists
|
||||
const host = await prisma.hosts.findUnique({
|
||||
where: { id: hostId },
|
||||
@@ -841,7 +1137,7 @@ router.put(
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
// If hostGroupId is provided, verify the group exists
|
||||
// Verify group exists if provided
|
||||
if (hostGroupId) {
|
||||
const hostGroup = await prisma.host_groups.findUnique({
|
||||
where: { id: hostGroupId },
|
||||
@@ -852,22 +1148,41 @@ router.put(
|
||||
}
|
||||
}
|
||||
|
||||
// Update host group
|
||||
const updatedHost = await prisma.hosts.update({
|
||||
where: { id: hostId },
|
||||
data: {
|
||||
host_group_id: hostGroupId || null,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
include: {
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
// Use transaction to update group memberships
|
||||
const updatedHost = await prisma.$transaction(async (tx) => {
|
||||
// Remove existing memberships
|
||||
await tx.host_group_memberships.deleteMany({
|
||||
where: { host_id: hostId },
|
||||
});
|
||||
|
||||
// Add new membership if group provided
|
||||
if (hostGroupId) {
|
||||
await tx.host_group_memberships.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
host_id: hostId,
|
||||
host_group_id: hostGroupId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Return updated host with groups
|
||||
return await tx.hosts.findUnique({
|
||||
where: { id: hostId },
|
||||
include: {
|
||||
host_group_memberships: {
|
||||
include: {
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
res.json({
|
||||
@@ -903,13 +1218,16 @@ router.get(
|
||||
agent_version: true,
|
||||
auto_update: true,
|
||||
created_at: true,
|
||||
host_group_id: true,
|
||||
notes: true,
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
host_group_memberships: {
|
||||
include: {
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1165,23 +1483,30 @@ router.get("/install", async (req, res) => {
|
||||
|
||||
// Determine curl flags dynamically from settings (ignore self-signed)
|
||||
let curlFlags = "-s";
|
||||
let skipSSLVerify = "false";
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
if (settings && settings.ignore_ssl_self_signed === true) {
|
||||
curlFlags = "-sk";
|
||||
skipSSLVerify = "true";
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Check for --force parameter
|
||||
const forceInstall = req.query.force === "true" || req.query.force === "1";
|
||||
|
||||
// Inject the API credentials, server URL, curl flags, and force flag into the script
|
||||
// Get architecture parameter (default to amd64)
|
||||
const architecture = req.query.arch || "amd64";
|
||||
|
||||
// Inject the API credentials, server URL, curl flags, SSL verify flag, force flag, and architecture into the script
|
||||
const envVars = `#!/bin/bash
|
||||
export PATCHMON_URL="${serverUrl}"
|
||||
export API_ID="${host.api_id}"
|
||||
export API_KEY="${host.api_key}"
|
||||
export CURL_FLAGS="${curlFlags}"
|
||||
export SKIP_SSL_VERIFY="${skipSSLVerify}"
|
||||
export FORCE_INSTALL="${forceInstall ? "true" : "false"}"
|
||||
export ARCHITECTURE="${architecture}"
|
||||
|
||||
`;
|
||||
|
||||
@@ -1558,16 +1883,16 @@ router.patch(
|
||||
architecture: true,
|
||||
last_update: true,
|
||||
status: true,
|
||||
host_group_id: true,
|
||||
agent_version: true,
|
||||
auto_update: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
host_group_memberships: {
|
||||
include: {
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1631,17 +1956,16 @@ router.patch(
|
||||
architecture: true,
|
||||
last_update: true,
|
||||
status: true,
|
||||
host_group_id: true,
|
||||
agent_version: true,
|
||||
auto_update: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
notes: true,
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
host_group_memberships: {
|
||||
include: {
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
242
backend/src/routes/integrationRoutes.js
Normal file
242
backend/src/routes/integrationRoutes.js
Normal file
@@ -0,0 +1,242 @@
|
||||
const express = require("express");
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const router = express.Router();
|
||||
|
||||
// POST /api/v1/integrations/docker - Docker data collection endpoint
|
||||
router.post("/docker", async (req, res) => {
|
||||
try {
|
||||
const apiId = req.headers["x-api-id"];
|
||||
const apiKey = req.headers["x-api-key"];
|
||||
const {
|
||||
containers,
|
||||
images,
|
||||
updates,
|
||||
daemon_info: _daemon_info,
|
||||
hostname,
|
||||
machine_id,
|
||||
agent_version: _agent_version,
|
||||
} = req.body;
|
||||
|
||||
console.log(
|
||||
`[Docker Integration] Received data from ${hostname || machine_id}`,
|
||||
);
|
||||
|
||||
// Validate API credentials
|
||||
const host = await prisma.hosts.findFirst({
|
||||
where: { api_id: apiId, api_key: apiKey },
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
console.warn("[Docker Integration] Invalid API credentials");
|
||||
return res.status(401).json({ error: "Invalid API credentials" });
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Docker Integration] Processing for host: ${host.friendly_name}`,
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Helper function to validate and parse dates
|
||||
const parseDate = (dateString) => {
|
||||
if (!dateString) return now;
|
||||
const date = new Date(dateString);
|
||||
return Number.isNaN(date.getTime()) ? now : date;
|
||||
};
|
||||
|
||||
let containersProcessed = 0;
|
||||
let imagesProcessed = 0;
|
||||
let updatesProcessed = 0;
|
||||
|
||||
// Process containers
|
||||
if (containers && Array.isArray(containers)) {
|
||||
console.log(
|
||||
`[Docker Integration] Processing ${containers.length} containers`,
|
||||
);
|
||||
for (const containerData of containers) {
|
||||
const containerId = uuidv4();
|
||||
|
||||
// Find or create image
|
||||
let imageId = null;
|
||||
if (containerData.image_repository && containerData.image_tag) {
|
||||
const image = await prisma.docker_images.upsert({
|
||||
where: {
|
||||
repository_tag_image_id: {
|
||||
repository: containerData.image_repository,
|
||||
tag: containerData.image_tag,
|
||||
image_id: containerData.image_id || "unknown",
|
||||
},
|
||||
},
|
||||
update: {
|
||||
last_checked: now,
|
||||
updated_at: now,
|
||||
},
|
||||
create: {
|
||||
id: uuidv4(),
|
||||
repository: containerData.image_repository,
|
||||
tag: containerData.image_tag,
|
||||
image_id: containerData.image_id || "unknown",
|
||||
source: containerData.image_source || "docker-hub",
|
||||
created_at: parseDate(containerData.created_at),
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
imageId = image.id;
|
||||
}
|
||||
|
||||
// Upsert container
|
||||
await prisma.docker_containers.upsert({
|
||||
where: {
|
||||
host_id_container_id: {
|
||||
host_id: host.id,
|
||||
container_id: containerData.container_id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
name: containerData.name,
|
||||
image_id: imageId,
|
||||
image_name: containerData.image_name,
|
||||
image_tag: containerData.image_tag || "latest",
|
||||
status: containerData.status,
|
||||
state: containerData.state || containerData.status,
|
||||
ports: containerData.ports || null,
|
||||
started_at: containerData.started_at
|
||||
? parseDate(containerData.started_at)
|
||||
: null,
|
||||
updated_at: now,
|
||||
last_checked: now,
|
||||
},
|
||||
create: {
|
||||
id: containerId,
|
||||
host_id: host.id,
|
||||
container_id: containerData.container_id,
|
||||
name: containerData.name,
|
||||
image_id: imageId,
|
||||
image_name: containerData.image_name,
|
||||
image_tag: containerData.image_tag || "latest",
|
||||
status: containerData.status,
|
||||
state: containerData.state || containerData.status,
|
||||
ports: containerData.ports || null,
|
||||
created_at: parseDate(containerData.created_at),
|
||||
started_at: containerData.started_at
|
||||
? parseDate(containerData.started_at)
|
||||
: null,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
containersProcessed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Process standalone images
|
||||
if (images && Array.isArray(images)) {
|
||||
console.log(`[Docker Integration] Processing ${images.length} images`);
|
||||
for (const imageData of images) {
|
||||
await prisma.docker_images.upsert({
|
||||
where: {
|
||||
repository_tag_image_id: {
|
||||
repository: imageData.repository,
|
||||
tag: imageData.tag,
|
||||
image_id: imageData.image_id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
size_bytes: imageData.size_bytes
|
||||
? BigInt(imageData.size_bytes)
|
||||
: null,
|
||||
digest: imageData.digest || null,
|
||||
last_checked: now,
|
||||
updated_at: now,
|
||||
},
|
||||
create: {
|
||||
id: uuidv4(),
|
||||
repository: imageData.repository,
|
||||
tag: imageData.tag,
|
||||
image_id: imageData.image_id,
|
||||
digest: imageData.digest,
|
||||
size_bytes: imageData.size_bytes
|
||||
? BigInt(imageData.size_bytes)
|
||||
: null,
|
||||
source: imageData.source || "docker-hub",
|
||||
created_at: parseDate(imageData.created_at),
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
imagesProcessed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Process updates
|
||||
if (updates && Array.isArray(updates)) {
|
||||
console.log(`[Docker Integration] Processing ${updates.length} updates`);
|
||||
for (const updateData of updates) {
|
||||
// Find the image by repository and image_id
|
||||
const image = await prisma.docker_images.findFirst({
|
||||
where: {
|
||||
repository: updateData.repository,
|
||||
tag: updateData.current_tag,
|
||||
image_id: updateData.image_id,
|
||||
},
|
||||
});
|
||||
|
||||
if (image) {
|
||||
// Store digest info in changelog_url field as JSON
|
||||
const digestInfo = JSON.stringify({
|
||||
method: "digest_comparison",
|
||||
current_digest: updateData.current_digest,
|
||||
available_digest: updateData.available_digest,
|
||||
});
|
||||
|
||||
// Upsert the update record
|
||||
await prisma.docker_image_updates.upsert({
|
||||
where: {
|
||||
image_id_available_tag: {
|
||||
image_id: image.id,
|
||||
available_tag: updateData.available_tag,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
updated_at: now,
|
||||
changelog_url: digestInfo,
|
||||
severity: "digest_changed",
|
||||
},
|
||||
create: {
|
||||
id: uuidv4(),
|
||||
image_id: image.id,
|
||||
current_tag: updateData.current_tag,
|
||||
available_tag: updateData.available_tag,
|
||||
severity: "digest_changed",
|
||||
changelog_url: digestInfo,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
updatesProcessed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Docker Integration] Successfully processed: ${containersProcessed} containers, ${imagesProcessed} images, ${updatesProcessed} updates`,
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: "Docker data collected successfully",
|
||||
containers_received: containersProcessed,
|
||||
images_received: imagesProcessed,
|
||||
updates_found: updatesProcessed,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Docker Integration] Error collecting Docker data:", error);
|
||||
console.error("[Docker Integration] Error stack:", error.stack);
|
||||
res.status(500).json({
|
||||
error: "Failed to collect Docker data",
|
||||
message: error.message,
|
||||
details: process.env.NODE_ENV === "development" ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
148
backend/src/routes/metricsRoutes.js
Normal file
148
backend/src/routes/metricsRoutes.js
Normal file
@@ -0,0 +1,148 @@
|
||||
const express = require("express");
|
||||
const { body, validationResult } = require("express-validator");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const { requireManageSettings } = require("../middleware/permissions");
|
||||
const { getSettings, updateSettings } = require("../services/settingsService");
|
||||
const { queueManager, QUEUE_NAMES } = require("../services/automation");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get metrics settings
|
||||
router.get("/", authenticateToken, requireManageSettings, async (_req, res) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
|
||||
// Generate anonymous ID if it doesn't exist
|
||||
if (!settings.metrics_anonymous_id) {
|
||||
const anonymousId = uuidv4();
|
||||
await updateSettings(settings.id, {
|
||||
metrics_anonymous_id: anonymousId,
|
||||
});
|
||||
settings.metrics_anonymous_id = anonymousId;
|
||||
}
|
||||
|
||||
res.json({
|
||||
metrics_enabled: settings.metrics_enabled ?? true,
|
||||
metrics_anonymous_id: settings.metrics_anonymous_id,
|
||||
metrics_last_sent: settings.metrics_last_sent,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Metrics settings fetch error:", error);
|
||||
res.status(500).json({ error: "Failed to fetch metrics settings" });
|
||||
}
|
||||
});
|
||||
|
||||
// Update metrics settings
|
||||
router.put(
|
||||
"/",
|
||||
authenticateToken,
|
||||
requireManageSettings,
|
||||
[
|
||||
body("metrics_enabled")
|
||||
.isBoolean()
|
||||
.withMessage("Metrics enabled must be a boolean"),
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { metrics_enabled } = req.body;
|
||||
const settings = await getSettings();
|
||||
|
||||
await updateSettings(settings.id, {
|
||||
metrics_enabled,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Metrics ${metrics_enabled ? "enabled" : "disabled"} by user`,
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: "Metrics settings updated successfully",
|
||||
metrics_enabled,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Metrics settings update error:", error);
|
||||
res.status(500).json({ error: "Failed to update metrics settings" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Regenerate anonymous ID
|
||||
router.post(
|
||||
"/regenerate-id",
|
||||
authenticateToken,
|
||||
requireManageSettings,
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
const newAnonymousId = uuidv4();
|
||||
|
||||
await updateSettings(settings.id, {
|
||||
metrics_anonymous_id: newAnonymousId,
|
||||
});
|
||||
|
||||
console.log("Anonymous ID regenerated");
|
||||
|
||||
res.json({
|
||||
message: "Anonymous ID regenerated successfully",
|
||||
metrics_anonymous_id: newAnonymousId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Anonymous ID regeneration error:", error);
|
||||
res.status(500).json({ error: "Failed to regenerate anonymous ID" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Manually send metrics now
|
||||
router.post(
|
||||
"/send-now",
|
||||
authenticateToken,
|
||||
requireManageSettings,
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
|
||||
if (!settings.metrics_enabled) {
|
||||
return res.status(400).json({
|
||||
error: "Metrics are disabled. Please enable metrics first.",
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger metrics directly (no queue delay for manual trigger)
|
||||
const metricsReporting =
|
||||
queueManager.automations[QUEUE_NAMES.METRICS_REPORTING];
|
||||
const result = await metricsReporting.process(
|
||||
{ name: "manual-send" },
|
||||
false,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log("✅ Manual metrics sent successfully");
|
||||
res.json({
|
||||
message: "Metrics sent successfully",
|
||||
data: result,
|
||||
});
|
||||
} else {
|
||||
console.error("❌ Failed to send metrics:", result);
|
||||
res.status(500).json({
|
||||
error: "Failed to send metrics",
|
||||
details: result.reason || result.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Send metrics error:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to send metrics",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,8 +1,8 @@
|
||||
const express = require("express");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Get all packages with their update status
|
||||
router.get("/", async (req, res) => {
|
||||
@@ -101,74 +101,107 @@ router.get("/", async (req, res) => {
|
||||
prisma.packages.count({ where }),
|
||||
]);
|
||||
|
||||
// Get additional stats for each package
|
||||
const packagesWithStats = await Promise.all(
|
||||
packages.map(async (pkg) => {
|
||||
// Build base where clause for this package
|
||||
const baseWhere = { package_id: pkg.id };
|
||||
// OPTIMIZATION: Batch query all stats instead of N individual queries
|
||||
const packageIds = packages.map((pkg) => pkg.id);
|
||||
|
||||
// If host filter is specified, add host filter to all queries
|
||||
const hostWhere = host ? { ...baseWhere, host_id: host } : baseWhere;
|
||||
|
||||
const [updatesCount, securityCount, packageHosts] = await Promise.all([
|
||||
prisma.host_packages.count({
|
||||
where: {
|
||||
...hostWhere,
|
||||
needs_update: true,
|
||||
},
|
||||
}),
|
||||
prisma.host_packages.count({
|
||||
where: {
|
||||
...hostWhere,
|
||||
needs_update: true,
|
||||
is_security_update: true,
|
||||
},
|
||||
}),
|
||||
prisma.host_packages.findMany({
|
||||
where: {
|
||||
...hostWhere,
|
||||
// If host filter is specified, include all packages for that host
|
||||
// Otherwise, only include packages that need updates
|
||||
...(host ? {} : { needs_update: true }),
|
||||
},
|
||||
select: {
|
||||
hosts: {
|
||||
select: {
|
||||
id: true,
|
||||
friendly_name: true,
|
||||
hostname: true,
|
||||
os_type: true,
|
||||
},
|
||||
},
|
||||
current_version: true,
|
||||
available_version: true,
|
||||
needs_update: true,
|
||||
is_security_update: true,
|
||||
},
|
||||
take: 10, // Limit to first 10 for performance
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
...pkg,
|
||||
packageHostsCount: pkg._count.host_packages,
|
||||
packageHosts: packageHosts.map((hp) => ({
|
||||
hostId: hp.hosts.id,
|
||||
friendlyName: hp.hosts.friendly_name,
|
||||
osType: hp.hosts.os_type,
|
||||
currentVersion: hp.current_version,
|
||||
availableVersion: hp.available_version,
|
||||
needsUpdate: hp.needs_update,
|
||||
isSecurityUpdate: hp.is_security_update,
|
||||
})),
|
||||
stats: {
|
||||
totalInstalls: pkg._count.host_packages,
|
||||
updatesNeeded: updatesCount,
|
||||
securityUpdates: securityCount,
|
||||
// Get all counts and host data in 3 batch queries instead of N*3 queries
|
||||
const [allUpdatesCounts, allSecurityCounts, allPackageHostsData] =
|
||||
await Promise.all([
|
||||
// Batch count all packages that need updates
|
||||
prisma.host_packages.groupBy({
|
||||
by: ["package_id"],
|
||||
where: {
|
||||
package_id: { in: packageIds },
|
||||
needs_update: true,
|
||||
...(host ? { host_id: host } : {}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
_count: { id: true },
|
||||
}),
|
||||
// Batch count all packages with security updates
|
||||
prisma.host_packages.groupBy({
|
||||
by: ["package_id"],
|
||||
where: {
|
||||
package_id: { in: packageIds },
|
||||
needs_update: true,
|
||||
is_security_update: true,
|
||||
...(host ? { host_id: host } : {}),
|
||||
},
|
||||
_count: { id: true },
|
||||
}),
|
||||
// Batch fetch all host data for packages
|
||||
prisma.host_packages.findMany({
|
||||
where: {
|
||||
package_id: { in: packageIds },
|
||||
...(host ? { host_id: host } : { needs_update: true }),
|
||||
},
|
||||
select: {
|
||||
package_id: true,
|
||||
hosts: {
|
||||
select: {
|
||||
id: true,
|
||||
friendly_name: true,
|
||||
hostname: true,
|
||||
os_type: true,
|
||||
},
|
||||
},
|
||||
current_version: true,
|
||||
available_version: true,
|
||||
needs_update: true,
|
||||
is_security_update: true,
|
||||
},
|
||||
// Limit to first 10 per package
|
||||
take: 100, // Increased from package-based limit
|
||||
}),
|
||||
]);
|
||||
|
||||
// Create lookup maps for O(1) access
|
||||
const updatesCountMap = new Map(
|
||||
allUpdatesCounts.map((item) => [item.package_id, item._count.id]),
|
||||
);
|
||||
const securityCountMap = new Map(
|
||||
allSecurityCounts.map((item) => [item.package_id, item._count.id]),
|
||||
);
|
||||
const packageHostsMap = new Map();
|
||||
|
||||
// Group host data by package_id
|
||||
for (const hp of allPackageHostsData) {
|
||||
if (!packageHostsMap.has(hp.package_id)) {
|
||||
packageHostsMap.set(hp.package_id, []);
|
||||
}
|
||||
const hosts = packageHostsMap.get(hp.package_id);
|
||||
hosts.push({
|
||||
hostId: hp.hosts.id,
|
||||
friendlyName: hp.hosts.friendly_name,
|
||||
osType: hp.hosts.os_type,
|
||||
currentVersion: hp.current_version,
|
||||
availableVersion: hp.available_version,
|
||||
needsUpdate: hp.needs_update,
|
||||
isSecurityUpdate: hp.is_security_update,
|
||||
});
|
||||
|
||||
// Limit to 10 hosts per package
|
||||
if (hosts.length > 10) {
|
||||
packageHostsMap.set(hp.package_id, hosts.slice(0, 10));
|
||||
}
|
||||
}
|
||||
|
||||
// Map packages with stats from lookup maps (no more DB queries!)
|
||||
const packagesWithStats = packages.map((pkg) => {
|
||||
const updatesCount = updatesCountMap.get(pkg.id) || 0;
|
||||
const securityCount = securityCountMap.get(pkg.id) || 0;
|
||||
const packageHosts = packageHostsMap.get(pkg.id) || [];
|
||||
|
||||
return {
|
||||
...pkg,
|
||||
packageHostsCount: pkg._count.host_packages,
|
||||
packageHosts,
|
||||
stats: {
|
||||
totalInstalls: pkg._count.host_packages,
|
||||
updatesNeeded: updatesCount,
|
||||
securityUpdates: securityCount,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
packages: packagesWithStats,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require("express");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const {
|
||||
requireManageSettings,
|
||||
@@ -7,7 +7,7 @@ const {
|
||||
} = require("../middleware/permissions");
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Get all role permissions (allow users who can manage users to view roles)
|
||||
router.get(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require("express");
|
||||
const { body, validationResult } = require("express-validator");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const {
|
||||
requireViewHosts,
|
||||
@@ -8,7 +8,7 @@ const {
|
||||
} = require("../middleware/permissions");
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Get all repositories with host count
|
||||
router.get("/", authenticateToken, requireViewHosts, async (_req, res) => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const { createPrismaClient } = require("../config/database");
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
|
||||
const prisma = createPrismaClient();
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
/**
|
||||
* Global search endpoint
|
||||
|
||||
@@ -1,109 +1,16 @@
|
||||
const express = require("express");
|
||||
const { body, validationResult } = require("express-validator");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const { requireManageSettings } = require("../middleware/permissions");
|
||||
const { getSettings, updateSettings } = require("../services/settingsService");
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Function to trigger crontab updates on all hosts with auto-update enabled
|
||||
async function triggerCrontabUpdates() {
|
||||
try {
|
||||
console.log(
|
||||
"Triggering crontab updates on all hosts with auto-update enabled...",
|
||||
);
|
||||
|
||||
// Get current settings for server URL
|
||||
const settings = await getSettings();
|
||||
const serverUrl = settings.server_url;
|
||||
|
||||
// Get all hosts that have auto-update enabled
|
||||
const hosts = await prisma.hosts.findMany({
|
||||
where: {
|
||||
auto_update: true,
|
||||
status: "active", // Only update active hosts
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
friendly_name: true,
|
||||
api_id: true,
|
||||
api_key: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Found ${hosts.length} hosts with auto-update enabled`);
|
||||
|
||||
// For each host, we'll send a special update command that triggers crontab update
|
||||
// This is done by sending a ping with a special flag
|
||||
for (const host of hosts) {
|
||||
try {
|
||||
console.log(
|
||||
`Triggering crontab update for host: ${host.friendly_name}`,
|
||||
);
|
||||
|
||||
// We'll use the existing ping endpoint but add a special parameter
|
||||
// The agent will detect this and run update-crontab command
|
||||
const http = require("node:http");
|
||||
const https = require("node:https");
|
||||
|
||||
const url = new URL(`${serverUrl}/api/v1/hosts/ping`);
|
||||
const isHttps = url.protocol === "https:";
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const postData = JSON.stringify({
|
||||
triggerCrontabUpdate: true,
|
||||
message: "Update interval changed, please update your crontab",
|
||||
});
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (isHttps ? 443 : 80),
|
||||
path: url.pathname,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(postData),
|
||||
"X-API-ID": host.api_id,
|
||||
"X-API-KEY": host.api_key,
|
||||
},
|
||||
};
|
||||
|
||||
const req = client.request(options, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
console.log(
|
||||
`Successfully triggered crontab update for ${host.friendly_name}`,
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
`Failed to trigger crontab update for ${host.friendly_name}: ${res.statusCode}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
req.on("error", (error) => {
|
||||
console.error(
|
||||
`Error triggering crontab update for ${host.friendly_name}:`,
|
||||
error.message,
|
||||
);
|
||||
});
|
||||
|
||||
req.write(postData);
|
||||
req.end();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error triggering crontab update for ${host.friendly_name}:`,
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Crontab update trigger completed");
|
||||
} catch (error) {
|
||||
console.error("Error in triggerCrontabUpdates:", error);
|
||||
}
|
||||
}
|
||||
// WebSocket broadcaster for agent policy updates (no longer used - queue-based delivery preferred)
|
||||
// const { broadcastSettingsUpdate } = require("../services/agentWs");
|
||||
const { queueManager, QUEUE_NAMES } = require("../services/automation");
|
||||
|
||||
// Helpers
|
||||
function normalizeUpdateInterval(minutes) {
|
||||
@@ -251,6 +158,7 @@ router.put(
|
||||
logoDark,
|
||||
logoLight,
|
||||
favicon,
|
||||
colorTheme,
|
||||
} = req.body;
|
||||
|
||||
// Get current settings to check for update interval changes
|
||||
@@ -282,6 +190,7 @@ router.put(
|
||||
if (logoDark !== undefined) updateData.logo_dark = logoDark;
|
||||
if (logoLight !== undefined) updateData.logo_light = logoLight;
|
||||
if (favicon !== undefined) updateData.favicon = favicon;
|
||||
if (colorTheme !== undefined) updateData.color_theme = colorTheme;
|
||||
|
||||
const updatedSettings = await updateSettings(
|
||||
currentSettings.id,
|
||||
@@ -290,15 +199,36 @@ router.put(
|
||||
|
||||
console.log("Settings updated successfully:", updatedSettings);
|
||||
|
||||
// If update interval changed, trigger crontab updates on all hosts with auto-update enabled
|
||||
// If update interval changed, enqueue persistent jobs for agents
|
||||
if (
|
||||
updateInterval !== undefined &&
|
||||
oldUpdateInterval !== updateData.update_interval
|
||||
) {
|
||||
console.log(
|
||||
`Update interval changed from ${oldUpdateInterval} to ${updateData.update_interval} minutes. Triggering crontab updates...`,
|
||||
`Update interval changed from ${oldUpdateInterval} to ${updateData.update_interval} minutes. Enqueueing agent settings updates...`,
|
||||
);
|
||||
await triggerCrontabUpdates();
|
||||
|
||||
const hosts = await prisma.hosts.findMany({
|
||||
where: { status: "active" },
|
||||
select: { api_id: true },
|
||||
});
|
||||
|
||||
const queue = queueManager.queues[QUEUE_NAMES.AGENT_COMMANDS];
|
||||
const jobs = hosts.map((h) => ({
|
||||
name: "settings_update",
|
||||
data: {
|
||||
api_id: h.api_id,
|
||||
type: "settings_update",
|
||||
update_interval: updateData.update_interval,
|
||||
},
|
||||
opts: { attempts: 10, backoff: { type: "exponential", delay: 5000 } },
|
||||
}));
|
||||
|
||||
// Bulk add jobs
|
||||
await queue.addBulk(jobs);
|
||||
|
||||
// Note: Queue-based delivery handles retries and ensures reliable delivery
|
||||
// No need for immediate broadcast as it would cause duplicate messages
|
||||
}
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
const express = require("express");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
const speakeasy = require("speakeasy");
|
||||
const QRCode = require("qrcode");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const { body, validationResult } = require("express-validator");
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Generate TFA secret and QR code
|
||||
router.get("/setup", authenticateToken, async (req, res) => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
const express = require("express");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const { requireManageSettings } = require("../middleware/permissions");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Default GitHub repository URL
|
||||
const DEFAULT_GITHUB_REPO = "https://github.com/patchMon/patchmon";
|
||||
const DEFAULT_GITHUB_REPO = "https://github.com/PatchMon/PatchMon.git";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -14,13 +14,16 @@ const router = express.Router();
|
||||
function getCurrentVersion() {
|
||||
try {
|
||||
const packageJson = require("../../package.json");
|
||||
return packageJson?.version || "1.2.9";
|
||||
if (!packageJson?.version) {
|
||||
throw new Error("Version not found in package.json");
|
||||
}
|
||||
return packageJson.version;
|
||||
} catch (packageError) {
|
||||
console.warn(
|
||||
"Could not read version from package.json, using fallback:",
|
||||
console.error(
|
||||
"Could not read version from package.json:",
|
||||
packageError.message,
|
||||
);
|
||||
return "1.2.9";
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
163
backend/src/routes/wsRoutes.js
Normal file
163
backend/src/routes/wsRoutes.js
Normal file
@@ -0,0 +1,163 @@
|
||||
const express = require("express");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const {
|
||||
getConnectionInfo,
|
||||
subscribeToConnectionChanges,
|
||||
} = require("../services/agentWs");
|
||||
const {
|
||||
validate_session,
|
||||
update_session_activity,
|
||||
} = require("../utils/session_manager");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get WebSocket connection status for multiple hosts at once (bulk endpoint)
|
||||
router.get("/status", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { apiIds } = req.query; // Comma-separated list of api_ids
|
||||
const idArray = apiIds ? apiIds.split(",").filter((id) => id.trim()) : [];
|
||||
|
||||
const statusMap = {};
|
||||
idArray.forEach((apiId) => {
|
||||
statusMap[apiId] = getConnectionInfo(apiId);
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: statusMap,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching bulk WebSocket status:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to fetch WebSocket status",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get WebSocket connection status by api_id (single endpoint)
|
||||
router.get("/status/:apiId", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { apiId } = req.params;
|
||||
|
||||
// Direct in-memory check - no database query needed
|
||||
const connectionInfo = getConnectionInfo(apiId);
|
||||
|
||||
// Minimal response for maximum speed
|
||||
res.json({
|
||||
success: true,
|
||||
data: connectionInfo,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching WebSocket status:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to fetch WebSocket status",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Server-Sent Events endpoint for real-time status updates (no polling needed!)
|
||||
router.get("/status/:apiId/stream", async (req, res) => {
|
||||
try {
|
||||
const { apiId } = req.params;
|
||||
|
||||
// Manual authentication for SSE (EventSource doesn't support custom headers)
|
||||
const token =
|
||||
req.query.token || req.headers.authorization?.replace("Bearer ", "");
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
// Verify token manually with session validation
|
||||
const jwt = require("jsonwebtoken");
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Validate session (same as regular auth middleware)
|
||||
const validation = await validate_session(decoded.sessionId, token);
|
||||
if (!validation.valid) {
|
||||
return res.status(401).json({ error: "Invalid or expired session" });
|
||||
}
|
||||
|
||||
// Update session activity to prevent inactivity timeout
|
||||
await update_session_activity(decoded.sessionId);
|
||||
|
||||
req.user = validation.user;
|
||||
} catch (_err) {
|
||||
return res.status(401).json({ error: "Invalid or expired token" });
|
||||
}
|
||||
|
||||
console.log("[SSE] Client connected for api_id:", apiId);
|
||||
|
||||
// Set headers for SSE
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no"); // Disable nginx buffering
|
||||
|
||||
// Send initial status immediately
|
||||
const initialInfo = getConnectionInfo(apiId);
|
||||
res.write(`data: ${JSON.stringify(initialInfo)}\n\n`);
|
||||
res.flushHeaders(); // Ensure headers are sent immediately
|
||||
|
||||
// Subscribe to connection changes for this specific api_id
|
||||
const unsubscribe = subscribeToConnectionChanges(apiId, (_connected) => {
|
||||
try {
|
||||
// Push update to client instantly when status changes
|
||||
const connectionInfo = getConnectionInfo(apiId);
|
||||
console.log(
|
||||
`[SSE] Pushing status change for ${apiId}: connected=${connectionInfo.connected} secure=${connectionInfo.secure}`,
|
||||
);
|
||||
res.write(`data: ${JSON.stringify(connectionInfo)}\n\n`);
|
||||
} catch (err) {
|
||||
console.error("[SSE] Error writing to stream:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// Heartbeat to keep connection alive (every 30 seconds)
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
res.write(": heartbeat\n\n");
|
||||
} catch (err) {
|
||||
console.error("[SSE] Error writing heartbeat:", err);
|
||||
clearInterval(heartbeat);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Cleanup on client disconnect
|
||||
req.on("close", () => {
|
||||
console.log("[SSE] Client disconnected for api_id:", apiId);
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
// Handle errors - distinguish between different error types
|
||||
req.on("error", (err) => {
|
||||
// Only log non-connection-reset errors to reduce noise
|
||||
if (err.code !== "ECONNRESET" && err.code !== "EPIPE") {
|
||||
console.error("[SSE] Request error:", err);
|
||||
} else {
|
||||
console.log("[SSE] Client connection reset for api_id:", apiId);
|
||||
}
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
// Handle response errors
|
||||
res.on("error", (err) => {
|
||||
if (err.code !== "ECONNRESET" && err.code !== "EPIPE") {
|
||||
console.error("[SSE] Response error:", err);
|
||||
}
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[SSE] Unexpected error:", error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -39,11 +39,12 @@ const express = require("express");
|
||||
const cors = require("cors");
|
||||
const helmet = require("helmet");
|
||||
const rateLimit = require("express-rate-limit");
|
||||
const cookieParser = require("cookie-parser");
|
||||
const {
|
||||
createPrismaClient,
|
||||
getPrismaClient,
|
||||
waitForDatabase,
|
||||
disconnectPrisma,
|
||||
} = require("./config/database");
|
||||
} = require("./config/prisma");
|
||||
const winston = require("winston");
|
||||
|
||||
// Import routes
|
||||
@@ -65,13 +66,19 @@ const autoEnrollmentRoutes = require("./routes/autoEnrollmentRoutes");
|
||||
const gethomepageRoutes = require("./routes/gethomepageRoutes");
|
||||
const automationRoutes = require("./routes/automationRoutes");
|
||||
const dockerRoutes = require("./routes/dockerRoutes");
|
||||
const updateScheduler = require("./services/updateScheduler");
|
||||
const integrationRoutes = require("./routes/integrationRoutes");
|
||||
const wsRoutes = require("./routes/wsRoutes");
|
||||
const agentVersionRoutes = require("./routes/agentVersionRoutes");
|
||||
const metricsRoutes = require("./routes/metricsRoutes");
|
||||
const { initSettings } = require("./services/settingsService");
|
||||
const { cleanup_expired_sessions } = require("./utils/session_manager");
|
||||
const { queueManager } = require("./services/automation");
|
||||
const { authenticateToken, requireAdmin } = require("./middleware/auth");
|
||||
const { createBullBoard } = require("@bull-board/api");
|
||||
const { BullMQAdapter } = require("@bull-board/api/bullMQAdapter");
|
||||
const { ExpressAdapter } = require("@bull-board/express");
|
||||
|
||||
// Initialize Prisma client with optimized connection pooling for multiple instances
|
||||
const prisma = createPrismaClient();
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Function to check and create default role permissions on startup
|
||||
async function checkAndCreateRolePermissions() {
|
||||
@@ -255,6 +262,10 @@ if (process.env.ENABLE_LOGGING === "true") {
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const http = require("node:http");
|
||||
const server = http.createServer(app);
|
||||
const { init: initAgentWs } = require("./services/agentWs");
|
||||
const agentVersionService = require("./services/agentVersionService");
|
||||
|
||||
// Trust proxy (needed when behind reverse proxy) and remove X-Powered-By
|
||||
if (process.env.TRUST_PROXY) {
|
||||
@@ -286,7 +297,7 @@ app.disable("x-powered-by");
|
||||
// Rate limiting with monitoring
|
||||
const limiter = rateLimit({
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 15 * 60 * 1000,
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 100,
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 5000,
|
||||
message: {
|
||||
error: "Too many requests from this IP, please try again later.",
|
||||
retryAfter: Math.ceil(
|
||||
@@ -336,18 +347,51 @@ const allowedOrigins = parseOrigins(
|
||||
process.env.CORS_ORIGIN ||
|
||||
"http://localhost:3000",
|
||||
);
|
||||
|
||||
// Add Bull Board origin to allowed origins if not already present
|
||||
const bullBoardOrigin = process.env.CORS_ORIGIN || "http://localhost:3000";
|
||||
if (!allowedOrigins.includes(bullBoardOrigin)) {
|
||||
allowedOrigins.push(bullBoardOrigin);
|
||||
}
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
// Allow non-browser/SSR tools with no origin
|
||||
if (!origin) return callback(null, true);
|
||||
if (allowedOrigins.includes(origin)) return callback(null, true);
|
||||
|
||||
// Allow Bull Board requests from the same origin as CORS_ORIGIN
|
||||
if (origin === bullBoardOrigin) return callback(null, true);
|
||||
|
||||
// Allow same-origin requests (e.g., Bull Board accessing its own API)
|
||||
// This allows http://hostname:3001 to make requests to http://hostname:3001
|
||||
if (origin?.includes(":3001")) return callback(null, true);
|
||||
|
||||
// Allow Bull Board requests from the frontend origin (same host, different port)
|
||||
// This handles cases where frontend is on port 3000 and backend on 3001
|
||||
const frontendOrigin = origin?.replace(/:3001$/, ":3000");
|
||||
if (frontendOrigin && allowedOrigins.includes(frontendOrigin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
return callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
// Additional CORS options for better cookie handling
|
||||
optionsSuccessStatus: 200,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"Cookie",
|
||||
"X-Requested-With",
|
||||
],
|
||||
}),
|
||||
);
|
||||
app.use(limiter);
|
||||
// Cookie parser for Bull Board sessions
|
||||
app.use(cookieParser());
|
||||
// Reduce body size limits to reasonable defaults
|
||||
app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || "5mb" }));
|
||||
app.use(
|
||||
@@ -382,7 +426,7 @@ const apiVersion = process.env.API_VERSION || "v1";
|
||||
const authLimiter = rateLimit({
|
||||
windowMs:
|
||||
parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 10) || 10 * 60 * 1000,
|
||||
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX, 10) || 20,
|
||||
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX, 10) || 500,
|
||||
message: {
|
||||
error: "Too many authentication requests, please try again later.",
|
||||
retryAfter: Math.ceil(
|
||||
@@ -396,7 +440,7 @@ const authLimiter = rateLimit({
|
||||
});
|
||||
const agentLimiter = rateLimit({
|
||||
windowMs: parseInt(process.env.AGENT_RATE_LIMIT_WINDOW_MS, 10) || 60 * 1000,
|
||||
max: parseInt(process.env.AGENT_RATE_LIMIT_MAX, 10) || 120,
|
||||
max: parseInt(process.env.AGENT_RATE_LIMIT_MAX, 10) || 1000,
|
||||
message: {
|
||||
error: "Too many agent requests, please try again later.",
|
||||
retryAfter: Math.ceil(
|
||||
@@ -429,12 +473,412 @@ app.use(
|
||||
app.use(`/api/${apiVersion}/gethomepage`, gethomepageRoutes);
|
||||
app.use(`/api/${apiVersion}/automation`, automationRoutes);
|
||||
app.use(`/api/${apiVersion}/docker`, dockerRoutes);
|
||||
app.use(`/api/${apiVersion}/integrations`, integrationRoutes);
|
||||
app.use(`/api/${apiVersion}/ws`, wsRoutes);
|
||||
app.use(`/api/${apiVersion}/agent`, agentVersionRoutes);
|
||||
app.use(`/api/${apiVersion}/metrics`, metricsRoutes);
|
||||
|
||||
// Bull Board - will be populated after queue manager initializes
|
||||
let bullBoardRouter = null;
|
||||
const _bullBoardSessions = new Map(); // Store authenticated sessions
|
||||
|
||||
// Mount Bull Board at /bullboard for cleaner URL
|
||||
app.use(`/bullboard`, (_req, res, next) => {
|
||||
// Relax COOP/COEP for Bull Board in non-production to avoid browser warnings
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
res.setHeader("Cross-Origin-Opener-Policy", "same-origin-allow-popups");
|
||||
res.setHeader("Cross-Origin-Embedder-Policy", "unsafe-none");
|
||||
}
|
||||
|
||||
// Add headers to help with WebSocket connections
|
||||
res.setHeader("X-Frame-Options", "SAMEORIGIN");
|
||||
res.setHeader(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' ws: wss:;",
|
||||
);
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Simplified Bull Board authentication - just validate token once and set a simple auth cookie
|
||||
app.use(`/bullboard`, async (req, res, next) => {
|
||||
// Skip authentication for static assets
|
||||
if (req.path.includes("/static/") || req.path.includes("/favicon")) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check for existing Bull Board auth cookie
|
||||
if (req.cookies["bull-board-auth"]) {
|
||||
// Already authenticated, allow access
|
||||
return next();
|
||||
}
|
||||
|
||||
// No auth cookie - check for token in query
|
||||
const token = req.query.token;
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
error:
|
||||
"Authentication required. Please access Bull Board from the Automation page.",
|
||||
});
|
||||
}
|
||||
|
||||
// Validate token and set auth cookie
|
||||
req.headers.authorization = `Bearer ${token}`;
|
||||
return authenticateToken(req, res, (err) => {
|
||||
if (err) {
|
||||
return res.status(401).json({ error: "Invalid authentication token" });
|
||||
}
|
||||
return requireAdmin(req, res, (adminErr) => {
|
||||
if (adminErr) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
// Set a simple auth cookie that will persist for the session
|
||||
res.cookie("bull-board-auth", token, {
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
maxAge: 3600000, // 1 hour
|
||||
path: "/bullboard",
|
||||
sameSite: "lax",
|
||||
});
|
||||
|
||||
console.log("Bull Board - Authentication successful, cookie set");
|
||||
return next();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Remove all the old complex middleware below and replace with the new Bull Board router setup
|
||||
app.use(`/bullboard`, (req, res, next) => {
|
||||
if (bullBoardRouter) {
|
||||
return bullBoardRouter(req, res, next);
|
||||
}
|
||||
return res.status(503).json({ error: "Bull Board not initialized yet" });
|
||||
});
|
||||
|
||||
/*
|
||||
// OLD MIDDLEWARE - REMOVED FOR SIMPLIFICATION - DO NOT USE
|
||||
if (false) {
|
||||
const sessionId = req.cookies["bull-board-session"];
|
||||
console.log("Bull Board API call - Session ID:", sessionId ? "present" : "missing");
|
||||
console.log("Bull Board API call - Cookies:", req.cookies);
|
||||
console.log("Bull Board API call - Bull Board token cookie:", req.cookies["bull-board-token"] ? "present" : "missing");
|
||||
console.log("Bull Board API call - Query token:", req.query.token ? "present" : "missing");
|
||||
console.log("Bull Board API call - Auth header:", req.headers.authorization ? "present" : "missing");
|
||||
console.log("Bull Board API call - Origin:", req.headers.origin || "missing");
|
||||
console.log("Bull Board API call - Referer:", req.headers.referer || "missing");
|
||||
|
||||
// Check if we have any authentication method available
|
||||
const hasSession = !!sessionId;
|
||||
const hasTokenCookie = !!req.cookies["bull-board-token"];
|
||||
const hasQueryToken = !!req.query.token;
|
||||
const hasAuthHeader = !!req.headers.authorization;
|
||||
const hasReferer = !!req.headers.referer;
|
||||
|
||||
console.log("Bull Board API call - Auth methods available:", {
|
||||
session: hasSession,
|
||||
tokenCookie: hasTokenCookie,
|
||||
queryToken: hasQueryToken,
|
||||
authHeader: hasAuthHeader,
|
||||
referer: hasReferer
|
||||
});
|
||||
|
||||
// Check for valid session first
|
||||
if (sessionId) {
|
||||
const session = bullBoardSessions.get(sessionId);
|
||||
console.log("Bull Board API call - Session found:", !!session);
|
||||
if (session && Date.now() - session.timestamp < 3600000) {
|
||||
// Valid session, extend it
|
||||
session.timestamp = Date.now();
|
||||
console.log("Bull Board API call - Using existing session, proceeding");
|
||||
return next();
|
||||
} else if (session) {
|
||||
// Expired session, remove it
|
||||
console.log("Bull Board API call - Session expired, removing");
|
||||
bullBoardSessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// No valid session, check for token as fallback
|
||||
let token = req.query.token;
|
||||
if (!token && req.headers.authorization) {
|
||||
token = req.headers.authorization.replace("Bearer ", "");
|
||||
}
|
||||
if (!token && req.cookies["bull-board-token"]) {
|
||||
token = req.cookies["bull-board-token"];
|
||||
}
|
||||
|
||||
// For API calls, also check if the token is in the referer URL
|
||||
// This handles cases where the main page hasn't set the cookie yet
|
||||
if (!token && req.headers.referer) {
|
||||
try {
|
||||
const refererUrl = new URL(req.headers.referer);
|
||||
const refererToken = refererUrl.searchParams.get('token');
|
||||
if (refererToken) {
|
||||
token = refererToken;
|
||||
console.log("Bull Board API call - Token found in referer URL:", refererToken.substring(0, 20) + "...");
|
||||
} else {
|
||||
console.log("Bull Board API call - No token found in referer URL");
|
||||
// If no token in referer and no session, return 401 with redirect info
|
||||
if (!sessionId) {
|
||||
console.log("Bull Board API call - No authentication available, returning 401");
|
||||
return res.status(401).json({
|
||||
error: "Authentication required",
|
||||
message: "Please refresh the page to re-authenticate"
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Bull Board API call - Error parsing referer URL:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (token) {
|
||||
console.log("Bull Board API call - Token found, authenticating");
|
||||
// Add token to headers for authentication
|
||||
req.headers.authorization = `Bearer ${token}`;
|
||||
|
||||
// Authenticate the user
|
||||
return authenticateToken(req, res, (err) => {
|
||||
if (err) {
|
||||
console.log("Bull Board API call - Token authentication failed");
|
||||
return res.status(401).json({ error: "Authentication failed" });
|
||||
}
|
||||
return requireAdmin(req, res, (adminErr) => {
|
||||
if (adminErr) {
|
||||
console.log("Bull Board API call - Admin access required");
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
console.log("Bull Board API call - Token authentication successful");
|
||||
return next();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// No valid session or token for API calls, deny access
|
||||
console.log("Bull Board API call - No valid session or token, denying access");
|
||||
return res.status(401).json({ error: "Valid Bull Board session or token required" });
|
||||
}
|
||||
|
||||
// Check for bull-board-session cookie first
|
||||
const sessionId = req.cookies["bull-board-session"];
|
||||
if (sessionId) {
|
||||
const session = bullBoardSessions.get(sessionId);
|
||||
if (session && Date.now() - session.timestamp < 3600000) {
|
||||
// 1 hour
|
||||
// Valid session, extend it
|
||||
session.timestamp = Date.now();
|
||||
return next();
|
||||
} else if (session) {
|
||||
// Expired session, remove it
|
||||
bullBoardSessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// No valid session, check for token
|
||||
let token = req.query.token;
|
||||
if (!token && req.headers.authorization) {
|
||||
token = req.headers.authorization.replace("Bearer ", "");
|
||||
}
|
||||
if (!token && req.cookies["bull-board-token"]) {
|
||||
token = req.cookies["bull-board-token"];
|
||||
}
|
||||
|
||||
// If no token, deny access
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: "Access token required" });
|
||||
}
|
||||
|
||||
// Add token to headers for authentication
|
||||
req.headers.authorization = `Bearer ${token}`;
|
||||
|
||||
// Authenticate the user
|
||||
return authenticateToken(req, res, (err) => {
|
||||
if (err) {
|
||||
return res.status(401).json({ error: "Authentication failed" });
|
||||
}
|
||||
return requireAdmin(req, res, (adminErr) => {
|
||||
if (adminErr) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
// Authentication successful - create a session
|
||||
const newSessionId = require("node:crypto")
|
||||
.randomBytes(32)
|
||||
.toString("hex");
|
||||
bullBoardSessions.set(newSessionId, {
|
||||
timestamp: Date.now(),
|
||||
userId: req.user.id,
|
||||
});
|
||||
|
||||
// Set session cookie with proper configuration for domain access
|
||||
const isHttps = process.env.NODE_ENV === "production" || process.env.SERVER_PROTOCOL === "https";
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: isHttps,
|
||||
maxAge: 3600000, // 1 hour
|
||||
path: "/", // Set path to root so it's available for all Bull Board requests
|
||||
};
|
||||
|
||||
// Configure sameSite based on protocol and environment
|
||||
if (isHttps) {
|
||||
cookieOptions.sameSite = "none"; // Required for HTTPS cross-origin
|
||||
} else {
|
||||
cookieOptions.sameSite = "lax"; // Better for HTTP same-origin
|
||||
}
|
||||
|
||||
res.cookie("bull-board-session", newSessionId, cookieOptions);
|
||||
|
||||
// Clean up old sessions periodically
|
||||
if (bullBoardSessions.size > 100) {
|
||||
const now = Date.now();
|
||||
for (const [sid, session] of bullBoardSessions.entries()) {
|
||||
if (now - session.timestamp > 3600000) {
|
||||
bullBoardSessions.delete(sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
||||
// Second middleware block - COMMENTED OUT - using simplified version above instead
|
||||
/*
|
||||
app.use(`/bullboard`, (req, res, next) => {
|
||||
if (bullBoardRouter) {
|
||||
// If this is the main Bull Board page (not an API call), inject the token and create session
|
||||
if (!req.path.includes("/api/") && !req.path.includes("/static/") && req.path === "/bullboard") {
|
||||
const token = req.query.token;
|
||||
console.log("Bull Board main page - Token:", token ? "present" : "missing");
|
||||
console.log("Bull Board main page - Query params:", req.query);
|
||||
console.log("Bull Board main page - Origin:", req.headers.origin || "missing");
|
||||
console.log("Bull Board main page - Referer:", req.headers.referer || "missing");
|
||||
console.log("Bull Board main page - Cookies:", req.cookies);
|
||||
|
||||
if (token) {
|
||||
// Authenticate the user and create a session immediately on page load
|
||||
req.headers.authorization = `Bearer ${token}`;
|
||||
|
||||
return authenticateToken(req, res, (err) => {
|
||||
if (err) {
|
||||
console.log("Bull Board main page - Token authentication failed");
|
||||
return res.status(401).json({ error: "Authentication failed" });
|
||||
}
|
||||
return requireAdmin(req, res, (adminErr) => {
|
||||
if (adminErr) {
|
||||
console.log("Bull Board main page - Admin access required");
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
console.log("Bull Board main page - Token authentication successful, creating session");
|
||||
|
||||
// Create a Bull Board session immediately
|
||||
const newSessionId = require("node:crypto")
|
||||
.randomBytes(32)
|
||||
.toString("hex");
|
||||
bullBoardSessions.set(newSessionId, {
|
||||
timestamp: Date.now(),
|
||||
userId: req.user.id,
|
||||
});
|
||||
|
||||
// Set session cookie with proper configuration for domain access
|
||||
const sessionCookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: false, // Always false for HTTP
|
||||
maxAge: 3600000, // 1 hour
|
||||
path: "/", // Set path to root so it's available for all Bull Board requests
|
||||
sameSite: "lax", // Always lax for HTTP
|
||||
};
|
||||
|
||||
res.cookie("bull-board-session", newSessionId, sessionCookieOptions);
|
||||
console.log("Bull Board main page - Session created:", newSessionId);
|
||||
console.log("Bull Board main page - Cookie options:", sessionCookieOptions);
|
||||
|
||||
// Also set a token cookie for API calls as a fallback
|
||||
const tokenCookieOptions = {
|
||||
httpOnly: false, // Allow JavaScript to access it
|
||||
secure: false, // Always false for HTTP
|
||||
maxAge: 3600000, // 1 hour
|
||||
path: "/", // Set path to root for broader compatibility
|
||||
sameSite: "lax", // Always lax for HTTP
|
||||
};
|
||||
|
||||
res.cookie("bull-board-token", token, tokenCookieOptions);
|
||||
console.log("Bull Board main page - Token cookie also set for API fallback");
|
||||
|
||||
// Clean up old sessions periodically
|
||||
if (bullBoardSessions.size > 100) {
|
||||
const now = Date.now();
|
||||
for (const [sid, session] of bullBoardSessions.entries()) {
|
||||
if (now - session.timestamp > 3600000) {
|
||||
bullBoardSessions.delete(sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now proceed to serve the Bull Board page
|
||||
return bullBoardRouter(req, res, next);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
console.log("Bull Board main page - No token provided, checking for existing session");
|
||||
// Check if we have an existing session
|
||||
const sessionId = req.cookies["bull-board-session"];
|
||||
if (sessionId) {
|
||||
const session = bullBoardSessions.get(sessionId);
|
||||
if (session && Date.now() - session.timestamp < 3600000) {
|
||||
console.log("Bull Board main page - Using existing session");
|
||||
// Extend session
|
||||
session.timestamp = Date.now();
|
||||
return bullBoardRouter(req, res, next);
|
||||
} else if (session) {
|
||||
console.log("Bull Board main page - Session expired, removing");
|
||||
bullBoardSessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
console.log("Bull Board main page - No valid session, denying access");
|
||||
return res.status(401).json({ error: "Access token required" });
|
||||
}
|
||||
}
|
||||
return bullBoardRouter(req, res, next);
|
||||
}
|
||||
return res.status(503).json({ error: "Bull Board not initialized yet" });
|
||||
});
|
||||
*/
|
||||
|
||||
// Error handler specifically for Bull Board routes
|
||||
app.use("/bullboard", (err, req, res, _next) => {
|
||||
console.error("Bull Board error on", req.method, req.url);
|
||||
console.error("Error details:", err.message);
|
||||
console.error("Stack:", err.stack);
|
||||
if (process.env.ENABLE_LOGGING === "true") {
|
||||
logger.error(`Bull Board error on ${req.method} ${req.url}:`, err);
|
||||
}
|
||||
res.status(500).json({
|
||||
error: "Internal server error",
|
||||
message: err.message,
|
||||
path: req.path,
|
||||
url: req.url,
|
||||
});
|
||||
});
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, _req, res, _next) => {
|
||||
if (process.env.ENABLE_LOGGING === "true") {
|
||||
logger.error(err.stack);
|
||||
}
|
||||
|
||||
// Special handling for CORS errors - always include the message
|
||||
if (err.message?.includes("Not allowed by CORS")) {
|
||||
return res.status(500).json({
|
||||
error: "Something went wrong!",
|
||||
message: err.message, // Always include CORS error message
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: "Something went wrong!",
|
||||
message: process.env.NODE_ENV === "development" ? err.message : undefined,
|
||||
@@ -451,10 +895,6 @@ process.on("SIGINT", async () => {
|
||||
if (process.env.ENABLE_LOGGING === "true") {
|
||||
logger.info("SIGINT received, shutting down gracefully");
|
||||
}
|
||||
if (app.locals.session_cleanup_interval) {
|
||||
clearInterval(app.locals.session_cleanup_interval);
|
||||
}
|
||||
updateScheduler.stop();
|
||||
await queueManager.shutdown();
|
||||
await disconnectPrisma(prisma);
|
||||
process.exit(0);
|
||||
@@ -464,10 +904,6 @@ process.on("SIGTERM", async () => {
|
||||
if (process.env.ENABLE_LOGGING === "true") {
|
||||
logger.info("SIGTERM received, shutting down gracefully");
|
||||
}
|
||||
if (app.locals.session_cleanup_interval) {
|
||||
clearInterval(app.locals.session_cleanup_interval);
|
||||
}
|
||||
updateScheduler.stop();
|
||||
await queueManager.shutdown();
|
||||
await disconnectPrisma(prisma);
|
||||
process.exit(0);
|
||||
@@ -743,34 +1179,44 @@ async function startServer() {
|
||||
// Schedule recurring jobs
|
||||
await queueManager.scheduleAllJobs();
|
||||
|
||||
// Initial session cleanup
|
||||
await cleanup_expired_sessions();
|
||||
// Set up Bull Board for queue monitoring
|
||||
const serverAdapter = new ExpressAdapter();
|
||||
// Set basePath to match where we mount the router
|
||||
serverAdapter.setBasePath("/bullboard");
|
||||
|
||||
// Schedule session cleanup every hour
|
||||
const session_cleanup_interval = setInterval(
|
||||
async () => {
|
||||
try {
|
||||
await cleanup_expired_sessions();
|
||||
} catch (error) {
|
||||
console.error("Session cleanup error:", error);
|
||||
}
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
); // Every hour
|
||||
const { QUEUE_NAMES } = require("./services/automation");
|
||||
const bullAdapters = Object.values(QUEUE_NAMES).map(
|
||||
(queueName) => new BullMQAdapter(queueManager.queues[queueName]),
|
||||
);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
createBullBoard({
|
||||
queues: bullAdapters,
|
||||
serverAdapter: serverAdapter,
|
||||
});
|
||||
|
||||
// Set the router for the Bull Board middleware (secured middleware above)
|
||||
bullBoardRouter = serverAdapter.getRouter();
|
||||
console.log("✅ Bull Board mounted at /bullboard (secured)");
|
||||
|
||||
// Initialize WS layer with the underlying HTTP server
|
||||
initAgentWs(server, prisma);
|
||||
await agentVersionService.initialize();
|
||||
|
||||
// Send metrics on startup (silent - no console output)
|
||||
try {
|
||||
const metricsReporting =
|
||||
queueManager.automations[QUEUE_NAMES.METRICS_REPORTING];
|
||||
await metricsReporting.sendSilent();
|
||||
} catch (_error) {
|
||||
// Silent failure - don't block server startup if metrics fail
|
||||
}
|
||||
|
||||
server.listen(PORT, () => {
|
||||
if (process.env.ENABLE_LOGGING === "true") {
|
||||
logger.info(`Server running on port ${PORT}`);
|
||||
logger.info(`Environment: ${process.env.NODE_ENV}`);
|
||||
logger.info("✅ Session cleanup scheduled (every hour)");
|
||||
}
|
||||
|
||||
// Start update scheduler
|
||||
updateScheduler.start();
|
||||
});
|
||||
|
||||
// Store interval for cleanup on shutdown
|
||||
app.locals.session_cleanup_interval = session_cleanup_interval;
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to start server:", error.message);
|
||||
process.exit(1);
|
||||
|
||||
746
backend/src/services/agentVersionService.js
Normal file
746
backend/src/services/agentVersionService.js
Normal file
@@ -0,0 +1,746 @@
|
||||
const axios = require("axios");
|
||||
const fs = require("node:fs").promises;
|
||||
const path = require("node:path");
|
||||
const { exec, spawn } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
const _execAsync = promisify(exec);
|
||||
|
||||
// Simple semver comparison function
|
||||
function compareVersions(version1, version2) {
|
||||
const v1parts = version1.split(".").map(Number);
|
||||
const v2parts = version2.split(".").map(Number);
|
||||
|
||||
// Ensure both arrays have the same length
|
||||
while (v1parts.length < 3) v1parts.push(0);
|
||||
while (v2parts.length < 3) v2parts.push(0);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (v1parts[i] > v2parts[i]) return 1;
|
||||
if (v1parts[i] < v2parts[i]) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
class AgentVersionService {
|
||||
constructor() {
|
||||
this.githubApiUrl =
|
||||
"https://api.github.com/repos/PatchMon/PatchMon-agent/releases";
|
||||
this.agentsDir = path.resolve(__dirname, "../../../agents");
|
||||
this.supportedArchitectures = [
|
||||
"linux-amd64",
|
||||
"linux-arm64",
|
||||
"linux-386",
|
||||
"linux-arm",
|
||||
];
|
||||
this.currentVersion = null;
|
||||
this.latestVersion = null;
|
||||
this.lastChecked = null;
|
||||
this.checkInterval = 30 * 60 * 1000; // 30 minutes
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
// Ensure agents directory exists
|
||||
await fs.mkdir(this.agentsDir, { recursive: true });
|
||||
|
||||
console.log("🔍 Testing GitHub API connectivity...");
|
||||
try {
|
||||
const testResponse = await axios.get(
|
||||
"https://api.github.com/repos/PatchMon/PatchMon-agent/releases",
|
||||
{
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
"User-Agent": "PatchMon-Server/1.0",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
},
|
||||
);
|
||||
console.log(
|
||||
`✅ GitHub API accessible - found ${testResponse.data.length} releases`,
|
||||
);
|
||||
} catch (testError) {
|
||||
console.error("❌ GitHub API not accessible:", testError.message);
|
||||
if (testError.response) {
|
||||
console.error(
|
||||
"❌ Status:",
|
||||
testError.response.status,
|
||||
testError.response.statusText,
|
||||
);
|
||||
if (testError.response.status === 403) {
|
||||
console.log("⚠️ GitHub API rate limit exceeded - will retry later");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get current agent version by executing the binary
|
||||
await this.getCurrentAgentVersion();
|
||||
|
||||
// Try to check for updates, but don't fail initialization if GitHub API is unavailable
|
||||
try {
|
||||
await this.checkForUpdates();
|
||||
} catch (updateError) {
|
||||
console.log(
|
||||
"⚠️ Failed to check for updates on startup, will retry later:",
|
||||
updateError.message,
|
||||
);
|
||||
}
|
||||
|
||||
// Set up periodic checking
|
||||
setInterval(() => {
|
||||
this.checkForUpdates().catch((error) => {
|
||||
console.log("⚠️ Periodic update check failed:", error.message);
|
||||
});
|
||||
}, this.checkInterval);
|
||||
|
||||
console.log("✅ Agent Version Service initialized");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"❌ Failed to initialize Agent Version Service:",
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentAgentVersion() {
|
||||
try {
|
||||
console.log("🔍 Getting current agent version...");
|
||||
|
||||
// Try to find the agent binary in agents/ folder only (what gets distributed)
|
||||
const possiblePaths = [
|
||||
path.join(this.agentsDir, "patchmon-agent-linux-amd64"),
|
||||
path.join(this.agentsDir, "patchmon-agent"),
|
||||
];
|
||||
|
||||
let agentPath = null;
|
||||
for (const testPath of possiblePaths) {
|
||||
try {
|
||||
await fs.access(testPath);
|
||||
agentPath = testPath;
|
||||
console.log(`✅ Found agent binary at: ${testPath}`);
|
||||
break;
|
||||
} catch {
|
||||
// Path doesn't exist, continue to next
|
||||
}
|
||||
}
|
||||
|
||||
if (!agentPath) {
|
||||
console.log(
|
||||
"⚠️ No agent binary found in agents/ folder, current version will be unknown",
|
||||
);
|
||||
console.log("💡 Use the Download Updates button to get agent binaries");
|
||||
this.currentVersion = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute the agent binary with help flag to get version info
|
||||
try {
|
||||
const child = spawn(agentPath, ["--help"], {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
child.on("close", (code) => {
|
||||
resolve({ stdout, stderr, code });
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
|
||||
if (result.stderr) {
|
||||
console.log("⚠️ Agent help stderr:", result.stderr);
|
||||
}
|
||||
|
||||
// Parse version from help output (e.g., "PatchMon Agent v1.3.0")
|
||||
const versionMatch = result.stdout.match(
|
||||
/PatchMon Agent v([0-9]+\.[0-9]+\.[0-9]+)/i,
|
||||
);
|
||||
if (versionMatch) {
|
||||
this.currentVersion = versionMatch[1];
|
||||
console.log(`✅ Current agent version: ${this.currentVersion}`);
|
||||
} else {
|
||||
console.log(
|
||||
"⚠️ Could not parse version from agent help output:",
|
||||
result.stdout,
|
||||
);
|
||||
this.currentVersion = null;
|
||||
}
|
||||
} catch (execError) {
|
||||
console.error("❌ Failed to execute agent binary:", execError.message);
|
||||
this.currentVersion = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to get current agent version:", error.message);
|
||||
this.currentVersion = null;
|
||||
}
|
||||
}
|
||||
|
||||
async checkForUpdates() {
|
||||
try {
|
||||
console.log("🔍 Checking for agent updates...");
|
||||
|
||||
const response = await axios.get(this.githubApiUrl, {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
"User-Agent": "PatchMon-Server/1.0",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`📡 GitHub API response status: ${response.status}`);
|
||||
console.log(`📦 Found ${response.data.length} releases`);
|
||||
|
||||
const releases = response.data;
|
||||
if (releases.length === 0) {
|
||||
console.log("ℹ️ No releases found");
|
||||
this.latestVersion = null;
|
||||
this.lastChecked = new Date();
|
||||
return {
|
||||
latestVersion: null,
|
||||
currentVersion: this.currentVersion,
|
||||
hasUpdate: false,
|
||||
lastChecked: this.lastChecked,
|
||||
};
|
||||
}
|
||||
|
||||
const latestRelease = releases[0];
|
||||
this.latestVersion = latestRelease.tag_name.replace("v", ""); // Remove 'v' prefix
|
||||
this.lastChecked = new Date();
|
||||
|
||||
console.log(`📦 Latest agent version: ${this.latestVersion}`);
|
||||
|
||||
// Don't download binaries automatically - only when explicitly requested
|
||||
console.log(
|
||||
"ℹ️ Skipping automatic binary download - binaries will be downloaded on demand",
|
||||
);
|
||||
|
||||
return {
|
||||
latestVersion: this.latestVersion,
|
||||
currentVersion: this.currentVersion,
|
||||
hasUpdate: this.currentVersion !== this.latestVersion,
|
||||
lastChecked: this.lastChecked,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to check for updates:", error.message);
|
||||
if (error.response) {
|
||||
console.error(
|
||||
"❌ GitHub API error:",
|
||||
error.response.status,
|
||||
error.response.statusText,
|
||||
);
|
||||
console.error(
|
||||
"❌ Rate limit info:",
|
||||
error.response.headers["x-ratelimit-remaining"],
|
||||
"/",
|
||||
error.response.headers["x-ratelimit-limit"],
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async downloadBinariesToAgentsFolder(release) {
|
||||
try {
|
||||
console.log(
|
||||
`⬇️ Downloading binaries for version ${release.tag_name} to agents folder...`,
|
||||
);
|
||||
|
||||
for (const arch of this.supportedArchitectures) {
|
||||
const assetName = `patchmon-agent-${arch}`;
|
||||
const asset = release.assets.find((a) => a.name === assetName);
|
||||
|
||||
if (!asset) {
|
||||
console.warn(`⚠️ Binary not found for architecture: ${arch}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const binaryPath = path.join(this.agentsDir, assetName);
|
||||
|
||||
console.log(`⬇️ Downloading ${assetName}...`);
|
||||
|
||||
const response = await axios.get(asset.browser_download_url, {
|
||||
responseType: "stream",
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
const writer = require("node:fs").createWriteStream(binaryPath);
|
||||
response.data.pipe(writer);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
writer.on("finish", resolve);
|
||||
writer.on("error", reject);
|
||||
});
|
||||
|
||||
// Make executable
|
||||
await fs.chmod(binaryPath, "755");
|
||||
|
||||
console.log(`✅ Downloaded: ${assetName} to agents folder`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"❌ Failed to download binaries to agents folder:",
|
||||
error.message,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async downloadBinaryForVersion(version, architecture) {
|
||||
try {
|
||||
console.log(
|
||||
`⬇️ Downloading binary for version ${version} architecture ${architecture}...`,
|
||||
);
|
||||
|
||||
// Get the release info from GitHub
|
||||
const response = await axios.get(this.githubApiUrl, {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
"User-Agent": "PatchMon-Server/1.0",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
});
|
||||
|
||||
const releases = response.data;
|
||||
const release = releases.find(
|
||||
(r) => r.tag_name.replace("v", "") === version,
|
||||
);
|
||||
|
||||
if (!release) {
|
||||
throw new Error(`Release ${version} not found`);
|
||||
}
|
||||
|
||||
const assetName = `patchmon-agent-${architecture}`;
|
||||
const asset = release.assets.find((a) => a.name === assetName);
|
||||
|
||||
if (!asset) {
|
||||
throw new Error(`Binary not found for architecture: ${architecture}`);
|
||||
}
|
||||
|
||||
const binaryPath = path.join(
|
||||
this.agentBinariesDir,
|
||||
`${release.tag_name}-${assetName}`,
|
||||
);
|
||||
|
||||
console.log(`⬇️ Downloading ${assetName}...`);
|
||||
|
||||
const downloadResponse = await axios.get(asset.browser_download_url, {
|
||||
responseType: "stream",
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
const writer = require("node:fs").createWriteStream(binaryPath);
|
||||
downloadResponse.data.pipe(writer);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
writer.on("finish", resolve);
|
||||
writer.on("error", reject);
|
||||
});
|
||||
|
||||
// Make executable
|
||||
await fs.chmod(binaryPath, "755");
|
||||
|
||||
console.log(`✅ Downloaded: ${assetName}`);
|
||||
return binaryPath;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ Failed to download binary ${version}-${architecture}:`,
|
||||
error.message,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getBinaryPath(version, architecture) {
|
||||
const binaryName = `patchmon-agent-${architecture}`;
|
||||
const binaryPath = path.join(this.agentsDir, binaryName);
|
||||
|
||||
try {
|
||||
await fs.access(binaryPath);
|
||||
return binaryPath;
|
||||
} catch {
|
||||
throw new Error(`Binary not found: ${binaryName} version ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
async serveBinary(version, architecture, res) {
|
||||
try {
|
||||
// Check if binary exists, if not download it
|
||||
const binaryPath = await this.getBinaryPath(version, architecture);
|
||||
const stats = await fs.stat(binaryPath);
|
||||
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="patchmon-agent-${architecture}"`,
|
||||
);
|
||||
res.setHeader("Content-Length", stats.size);
|
||||
|
||||
// Add cache headers
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
res.setHeader("ETag", `"${version}-${architecture}"`);
|
||||
|
||||
const stream = require("node:fs").createReadStream(binaryPath);
|
||||
stream.pipe(res);
|
||||
} catch (_error) {
|
||||
// Binary doesn't exist, try to download it
|
||||
console.log(
|
||||
`⬇️ Binary not found locally, attempting to download ${version}-${architecture}...`,
|
||||
);
|
||||
try {
|
||||
await this.downloadBinaryForVersion(version, architecture);
|
||||
// Retry serving the binary
|
||||
const binaryPath = await this.getBinaryPath(version, architecture);
|
||||
const stats = await fs.stat(binaryPath);
|
||||
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="patchmon-agent-${architecture}"`,
|
||||
);
|
||||
res.setHeader("Content-Length", stats.size);
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
res.setHeader("ETag", `"${version}-${architecture}"`);
|
||||
|
||||
const stream = require("node:fs").createReadStream(binaryPath);
|
||||
stream.pipe(res);
|
||||
} catch (downloadError) {
|
||||
console.error(
|
||||
`❌ Failed to download binary ${version}-${architecture}:`,
|
||||
downloadError.message,
|
||||
);
|
||||
res
|
||||
.status(404)
|
||||
.json({ error: "Binary not found and could not be downloaded" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getVersionInfo() {
|
||||
let hasUpdate = false;
|
||||
let updateStatus = "unknown";
|
||||
|
||||
// Latest version should ALWAYS come from GitHub, not from local binaries
|
||||
// currentVersion = what's installed locally
|
||||
// latestVersion = what's available on GitHub
|
||||
if (this.latestVersion) {
|
||||
console.log(`📦 Latest version from GitHub: ${this.latestVersion}`);
|
||||
} else {
|
||||
console.log(
|
||||
`⚠️ No GitHub release version available (API may be unavailable)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.currentVersion) {
|
||||
console.log(`💾 Current local agent version: ${this.currentVersion}`);
|
||||
} else {
|
||||
console.log(`⚠️ No local agent binary found`);
|
||||
}
|
||||
|
||||
// Determine update status by comparing current vs latest (from GitHub)
|
||||
if (this.currentVersion && this.latestVersion) {
|
||||
const comparison = compareVersions(
|
||||
this.currentVersion,
|
||||
this.latestVersion,
|
||||
);
|
||||
if (comparison < 0) {
|
||||
hasUpdate = true;
|
||||
updateStatus = "update-available";
|
||||
} else if (comparison > 0) {
|
||||
hasUpdate = false;
|
||||
updateStatus = "newer-version";
|
||||
} else {
|
||||
hasUpdate = false;
|
||||
updateStatus = "up-to-date";
|
||||
}
|
||||
} else if (this.latestVersion && !this.currentVersion) {
|
||||
hasUpdate = true;
|
||||
updateStatus = "no-agent";
|
||||
} else if (this.currentVersion && !this.latestVersion) {
|
||||
// We have a current version but no latest version (GitHub API unavailable)
|
||||
hasUpdate = false;
|
||||
updateStatus = "github-unavailable";
|
||||
} else if (!this.currentVersion && !this.latestVersion) {
|
||||
updateStatus = "no-data";
|
||||
}
|
||||
|
||||
return {
|
||||
currentVersion: this.currentVersion,
|
||||
latestVersion: this.latestVersion, // Always return GitHub version, not local
|
||||
hasUpdate: hasUpdate,
|
||||
updateStatus: updateStatus,
|
||||
lastChecked: this.lastChecked,
|
||||
supportedArchitectures: this.supportedArchitectures,
|
||||
status: this.latestVersion ? "ready" : "no-releases",
|
||||
};
|
||||
}
|
||||
|
||||
async refreshCurrentVersion() {
|
||||
await this.getCurrentAgentVersion();
|
||||
return this.currentVersion;
|
||||
}
|
||||
|
||||
async downloadLatestUpdate() {
|
||||
try {
|
||||
console.log("⬇️ Downloading latest agent update...");
|
||||
|
||||
// First check for updates to get the latest release info
|
||||
const _updateInfo = await this.checkForUpdates();
|
||||
|
||||
if (!this.latestVersion) {
|
||||
throw new Error("No latest version available to download");
|
||||
}
|
||||
|
||||
// Get the release info from GitHub
|
||||
const response = await axios.get(this.githubApiUrl, {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
"User-Agent": "PatchMon-Server/1.0",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
});
|
||||
|
||||
const releases = response.data;
|
||||
const latestRelease = releases[0];
|
||||
|
||||
if (!latestRelease) {
|
||||
throw new Error("No releases found");
|
||||
}
|
||||
|
||||
console.log(
|
||||
`⬇️ Downloading binaries for version ${latestRelease.tag_name}...`,
|
||||
);
|
||||
|
||||
// Download binaries for all architectures directly to agents folder
|
||||
await this.downloadBinariesToAgentsFolder(latestRelease);
|
||||
|
||||
console.log("✅ Latest update downloaded successfully");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
version: this.latestVersion,
|
||||
downloadedArchitectures: this.supportedArchitectures,
|
||||
message: `Successfully downloaded version ${this.latestVersion}`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to download latest update:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailableVersions() {
|
||||
// No local caching - only return latest from GitHub
|
||||
if (this.latestVersion) {
|
||||
return [this.latestVersion];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async getBinaryInfo(version, architecture) {
|
||||
try {
|
||||
// Always use local version if it matches the requested version
|
||||
if (version === this.currentVersion && this.currentVersion) {
|
||||
const binaryPath = await this.getBinaryPath(
|
||||
this.currentVersion,
|
||||
architecture,
|
||||
);
|
||||
const stats = await fs.stat(binaryPath);
|
||||
|
||||
// Calculate file hash
|
||||
const fileBuffer = await fs.readFile(binaryPath);
|
||||
const hash = crypto
|
||||
.createHash("sha256")
|
||||
.update(fileBuffer)
|
||||
.digest("hex");
|
||||
|
||||
return {
|
||||
version: this.currentVersion,
|
||||
architecture,
|
||||
size: stats.size,
|
||||
hash,
|
||||
lastModified: stats.mtime,
|
||||
path: binaryPath,
|
||||
};
|
||||
}
|
||||
|
||||
// For other versions, try to find them in the agents folder
|
||||
const binaryPath = await this.getBinaryPath(version, architecture);
|
||||
const stats = await fs.stat(binaryPath);
|
||||
|
||||
// Calculate file hash
|
||||
const fileBuffer = await fs.readFile(binaryPath);
|
||||
const hash = crypto.createHash("sha256").update(fileBuffer).digest("hex");
|
||||
|
||||
return {
|
||||
version,
|
||||
architecture,
|
||||
size: stats.size,
|
||||
hash,
|
||||
lastModified: stats.mtime,
|
||||
path: binaryPath,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get binary info: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an agent needs an update and push notification if needed
|
||||
* @param {string} agentApiId - The agent's API ID
|
||||
* @param {string} agentVersion - The agent's current version
|
||||
* @param {boolean} force - Force update regardless of version
|
||||
* @returns {Object} Update check result
|
||||
*/
|
||||
async checkAndPushAgentUpdate(agentApiId, agentVersion, force = false) {
|
||||
try {
|
||||
console.log(
|
||||
`🔍 Checking update for agent ${agentApiId} (version: ${agentVersion})`,
|
||||
);
|
||||
|
||||
// Get current server version info
|
||||
const versionInfo = await this.getVersionInfo();
|
||||
|
||||
if (!versionInfo.latestVersion) {
|
||||
console.log(`⚠️ No latest version available for agent ${agentApiId}`);
|
||||
return {
|
||||
needsUpdate: false,
|
||||
reason: "no-latest-version",
|
||||
message: "No latest version available on server",
|
||||
};
|
||||
}
|
||||
|
||||
// Compare versions
|
||||
const comparison = compareVersions(
|
||||
agentVersion,
|
||||
versionInfo.latestVersion,
|
||||
);
|
||||
const needsUpdate = force || comparison < 0;
|
||||
|
||||
if (needsUpdate) {
|
||||
console.log(
|
||||
`📤 Agent ${agentApiId} needs update: ${agentVersion} → ${versionInfo.latestVersion}`,
|
||||
);
|
||||
|
||||
// Import agentWs service to push notification
|
||||
const { pushUpdateNotification } = require("./agentWs");
|
||||
|
||||
const updateInfo = {
|
||||
version: versionInfo.latestVersion,
|
||||
force: force,
|
||||
downloadUrl: `/api/v1/agent/binary/${versionInfo.latestVersion}/linux-amd64`,
|
||||
message: force
|
||||
? "Force update requested"
|
||||
: `Update available: ${versionInfo.latestVersion}`,
|
||||
};
|
||||
|
||||
const pushed = pushUpdateNotification(agentApiId, updateInfo);
|
||||
|
||||
if (pushed) {
|
||||
console.log(`✅ Update notification pushed to agent ${agentApiId}`);
|
||||
return {
|
||||
needsUpdate: true,
|
||||
reason: force ? "force-update" : "version-outdated",
|
||||
message: `Update notification sent: ${agentVersion} → ${versionInfo.latestVersion}`,
|
||||
targetVersion: versionInfo.latestVersion,
|
||||
};
|
||||
} else {
|
||||
console.log(
|
||||
`⚠️ Failed to push update notification to agent ${agentApiId} (not connected)`,
|
||||
);
|
||||
return {
|
||||
needsUpdate: true,
|
||||
reason: "agent-offline",
|
||||
message: "Agent needs update but is not connected",
|
||||
targetVersion: versionInfo.latestVersion,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
console.log(`✅ Agent ${agentApiId} is up to date: ${agentVersion}`);
|
||||
return {
|
||||
needsUpdate: false,
|
||||
reason: "up-to-date",
|
||||
message: `Agent is up to date: ${agentVersion}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ Failed to check update for agent ${agentApiId}:`,
|
||||
error.message,
|
||||
);
|
||||
return {
|
||||
needsUpdate: false,
|
||||
reason: "error",
|
||||
message: `Error checking update: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and push updates to all connected agents
|
||||
* @param {boolean} force - Force update regardless of version
|
||||
* @returns {Object} Bulk update result
|
||||
*/
|
||||
async checkAndPushUpdatesToAll(force = false) {
|
||||
try {
|
||||
console.log(
|
||||
`🔍 Checking updates for all connected agents (force: ${force})`,
|
||||
);
|
||||
|
||||
// Import agentWs service to get connected agents
|
||||
const { pushUpdateNotificationToAll } = require("./agentWs");
|
||||
|
||||
const versionInfo = await this.getVersionInfo();
|
||||
|
||||
if (!versionInfo.latestVersion) {
|
||||
return {
|
||||
success: false,
|
||||
message: "No latest version available on server",
|
||||
updatedAgents: 0,
|
||||
totalAgents: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const updateInfo = {
|
||||
version: versionInfo.latestVersion,
|
||||
force: force,
|
||||
downloadUrl: `/api/v1/agent/binary/${versionInfo.latestVersion}/linux-amd64`,
|
||||
message: force
|
||||
? "Force update requested for all agents"
|
||||
: `Update available: ${versionInfo.latestVersion}`,
|
||||
};
|
||||
|
||||
const result = await pushUpdateNotificationToAll(updateInfo);
|
||||
|
||||
console.log(
|
||||
`✅ Bulk update notification sent to ${result.notifiedCount} agents`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Update notifications sent to ${result.notifiedCount} agents`,
|
||||
updatedAgents: result.notifiedCount,
|
||||
totalAgents: result.totalAgents,
|
||||
targetVersion: versionInfo.latestVersion,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to push updates to all agents:", error.message);
|
||||
return {
|
||||
success: false,
|
||||
message: `Error pushing updates: ${error.message}`,
|
||||
updatedAgents: 0,
|
||||
totalAgents: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AgentVersionService();
|
||||
352
backend/src/services/agentWs.js
Normal file
352
backend/src/services/agentWs.js
Normal file
@@ -0,0 +1,352 @@
|
||||
// Lightweight WebSocket hub for agent connections
|
||||
// Auth: X-API-ID / X-API-KEY headers on the upgrade request
|
||||
|
||||
const WebSocket = require("ws");
|
||||
const url = require("node:url");
|
||||
|
||||
// Connection registry by api_id
|
||||
const apiIdToSocket = new Map();
|
||||
|
||||
// Connection metadata (secure/insecure)
|
||||
// Map<api_id, { ws: WebSocket, secure: boolean }>
|
||||
const connectionMetadata = new Map();
|
||||
|
||||
// Subscribers for connection status changes (for SSE)
|
||||
// Map<api_id, Set<callback>>
|
||||
const connectionChangeSubscribers = new Map();
|
||||
|
||||
let wss;
|
||||
let prisma;
|
||||
|
||||
function init(server, prismaClient) {
|
||||
prisma = prismaClient;
|
||||
wss = new WebSocket.Server({ noServer: true });
|
||||
|
||||
// Handle HTTP upgrade events and authenticate before accepting WS
|
||||
server.on("upgrade", async (request, socket, head) => {
|
||||
try {
|
||||
const { pathname } = url.parse(request.url);
|
||||
if (!pathname) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Bull Board WebSocket connections
|
||||
if (pathname.startsWith("/bullboard")) {
|
||||
// For Bull Board, we need to check if the user is authenticated
|
||||
// Check for session cookie or authorization header
|
||||
const sessionCookie = request.headers.cookie?.match(
|
||||
/bull-board-session=([^;]+)/,
|
||||
)?.[1];
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
if (!sessionCookie && !authHeader) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Accept the WebSocket connection for Bull Board
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
ws.on("message", (message) => {
|
||||
// Echo back for Bull Board WebSocket
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle agent WebSocket connections
|
||||
if (!pathname.startsWith("/api/")) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Expected path: /api/{v}/agents/ws
|
||||
const parts = pathname.split("/").filter(Boolean); // [api, v1, agents, ws]
|
||||
if (parts.length !== 4 || parts[2] !== "agents" || parts[3] !== "ws") {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const apiId = request.headers["x-api-id"];
|
||||
const apiKey = request.headers["x-api-key"];
|
||||
if (!apiId || !apiKey) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate credentials
|
||||
const host = await prisma.hosts.findUnique({ where: { api_id: apiId } });
|
||||
if (!host || host.api_key !== apiKey) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
ws.apiId = apiId;
|
||||
|
||||
// Detect if connection is secure (wss://) or not (ws://)
|
||||
const isSecure =
|
||||
socket.encrypted || request.headers["x-forwarded-proto"] === "https";
|
||||
|
||||
apiIdToSocket.set(apiId, ws);
|
||||
connectionMetadata.set(apiId, { ws, secure: isSecure });
|
||||
|
||||
console.log(
|
||||
`[agent-ws] connected api_id=${apiId} protocol=${isSecure ? "wss" : "ws"} total=${apiIdToSocket.size}`,
|
||||
);
|
||||
|
||||
// Notify subscribers of connection
|
||||
notifyConnectionChange(apiId, true);
|
||||
|
||||
ws.on("message", async (data) => {
|
||||
// Handle incoming messages from agent (e.g., Docker status updates)
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
if (message.type === "docker_status") {
|
||||
// Handle Docker container status events
|
||||
await handleDockerStatusEvent(apiId, message);
|
||||
}
|
||||
// Add more message types here as needed
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[agent-ws] error parsing message from ${apiId}:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
const existing = apiIdToSocket.get(apiId);
|
||||
if (existing === ws) {
|
||||
apiIdToSocket.delete(apiId);
|
||||
connectionMetadata.delete(apiId);
|
||||
// Notify subscribers of disconnection
|
||||
notifyConnectionChange(apiId, false);
|
||||
}
|
||||
console.log(
|
||||
`[agent-ws] disconnected api_id=${apiId} total=${apiIdToSocket.size}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Optional: greet/ack
|
||||
safeSend(ws, JSON.stringify({ type: "connected" }));
|
||||
});
|
||||
} catch (_err) {
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function safeSend(ws, data) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(data);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastSettingsUpdate(newInterval) {
|
||||
const payload = JSON.stringify({
|
||||
type: "settings_update",
|
||||
update_interval: newInterval,
|
||||
});
|
||||
for (const [, ws] of apiIdToSocket) {
|
||||
safeSend(ws, payload);
|
||||
}
|
||||
}
|
||||
|
||||
function pushReportNow(apiId) {
|
||||
const ws = apiIdToSocket.get(apiId);
|
||||
safeSend(ws, JSON.stringify({ type: "report_now" }));
|
||||
}
|
||||
|
||||
function pushSettingsUpdate(apiId, newInterval) {
|
||||
const ws = apiIdToSocket.get(apiId);
|
||||
safeSend(
|
||||
ws,
|
||||
JSON.stringify({ type: "settings_update", update_interval: newInterval }),
|
||||
);
|
||||
}
|
||||
|
||||
function pushUpdateNotification(apiId, updateInfo) {
|
||||
const ws = apiIdToSocket.get(apiId);
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
safeSend(
|
||||
ws,
|
||||
JSON.stringify({
|
||||
type: "update_notification",
|
||||
version: updateInfo.version,
|
||||
force: updateInfo.force || false,
|
||||
downloadUrl: updateInfo.downloadUrl,
|
||||
message: updateInfo.message,
|
||||
}),
|
||||
);
|
||||
console.log(
|
||||
`📤 Pushed update notification to agent ${apiId}: version ${updateInfo.version}`,
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
console.log(
|
||||
`⚠️ Agent ${apiId} not connected, cannot push update notification`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pushUpdateNotificationToAll(updateInfo) {
|
||||
let notifiedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const [apiId, ws] of apiIdToSocket) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
safeSend(
|
||||
ws,
|
||||
JSON.stringify({
|
||||
type: "update_notification",
|
||||
version: updateInfo.version,
|
||||
force: updateInfo.force || false,
|
||||
message: updateInfo.message,
|
||||
}),
|
||||
);
|
||||
notifiedCount++;
|
||||
console.log(
|
||||
`📤 Pushed update notification to agent ${apiId}: version ${updateInfo.version}`,
|
||||
);
|
||||
} catch (error) {
|
||||
failedCount++;
|
||||
console.error(`❌ Failed to notify agent ${apiId}:`, error.message);
|
||||
}
|
||||
} else {
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`📤 Update notification sent to ${notifiedCount} agents, ${failedCount} failed`,
|
||||
);
|
||||
return { notifiedCount, failedCount };
|
||||
}
|
||||
|
||||
// Notify all subscribers when connection status changes
|
||||
function notifyConnectionChange(apiId, connected) {
|
||||
const subscribers = connectionChangeSubscribers.get(apiId);
|
||||
if (subscribers) {
|
||||
for (const callback of subscribers) {
|
||||
try {
|
||||
callback(connected);
|
||||
} catch (err) {
|
||||
console.error(`[agent-ws] error notifying subscriber:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to connection status changes for a specific api_id
|
||||
function subscribeToConnectionChanges(apiId, callback) {
|
||||
if (!connectionChangeSubscribers.has(apiId)) {
|
||||
connectionChangeSubscribers.set(apiId, new Set());
|
||||
}
|
||||
connectionChangeSubscribers.get(apiId).add(callback);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const subscribers = connectionChangeSubscribers.get(apiId);
|
||||
if (subscribers) {
|
||||
subscribers.delete(callback);
|
||||
if (subscribers.size === 0) {
|
||||
connectionChangeSubscribers.delete(apiId);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Handle Docker container status events from agent
|
||||
async function handleDockerStatusEvent(apiId, message) {
|
||||
try {
|
||||
const { event: _event, container_id, name, status, timestamp } = message;
|
||||
|
||||
console.log(
|
||||
`[Docker Event] ${apiId}: Container ${name} (${container_id}) - ${status}`,
|
||||
);
|
||||
|
||||
// Find the host
|
||||
const host = await prisma.hosts.findUnique({
|
||||
where: { api_id: apiId },
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
console.error(`[Docker Event] Host not found for api_id: ${apiId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update container status in database
|
||||
const container = await prisma.docker_containers.findUnique({
|
||||
where: {
|
||||
host_id_container_id: {
|
||||
host_id: host.id,
|
||||
container_id: container_id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (container) {
|
||||
await prisma.docker_containers.update({
|
||||
where: { id: container.id },
|
||||
data: {
|
||||
status: status,
|
||||
state: status,
|
||||
updated_at: new Date(timestamp || Date.now()),
|
||||
last_checked: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Docker Event] Updated container ${name} status to ${status}`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[Docker Event] Container ${name} not found in database (may be new)`,
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Broadcast to connected dashboard clients via SSE or WebSocket
|
||||
// This would notify the frontend UI in real-time
|
||||
} catch (error) {
|
||||
console.error(`[Docker Event] Error handling Docker status event:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
broadcastSettingsUpdate,
|
||||
pushReportNow,
|
||||
pushSettingsUpdate,
|
||||
pushUpdateNotification,
|
||||
pushUpdateNotificationToAll,
|
||||
// Expose read-only view of connected agents
|
||||
getConnectedApiIds: () => Array.from(apiIdToSocket.keys()),
|
||||
isConnected: (apiId) => {
|
||||
const ws = apiIdToSocket.get(apiId);
|
||||
return !!ws && ws.readyState === WebSocket.OPEN;
|
||||
},
|
||||
// Get connection info including protocol (ws/wss)
|
||||
getConnectionInfo: (apiId) => {
|
||||
const metadata = connectionMetadata.get(apiId);
|
||||
if (!metadata) {
|
||||
return { connected: false, secure: false };
|
||||
}
|
||||
const connected = metadata.ws.readyState === WebSocket.OPEN;
|
||||
return { connected, secure: metadata.secure };
|
||||
},
|
||||
// Subscribe to connection status changes (for SSE)
|
||||
subscribeToConnectionChanges,
|
||||
};
|
||||
164
backend/src/services/automation/dockerInventoryCleanup.js
Normal file
164
backend/src/services/automation/dockerInventoryCleanup.js
Normal file
@@ -0,0 +1,164 @@
|
||||
const { prisma } = require("./shared/prisma");
|
||||
|
||||
/**
|
||||
* Docker Inventory Cleanup Automation
|
||||
* Removes Docker containers and images for hosts that no longer exist
|
||||
*/
|
||||
class DockerInventoryCleanup {
|
||||
constructor(queueManager) {
|
||||
this.queueManager = queueManager;
|
||||
this.queueName = "docker-inventory-cleanup";
|
||||
}
|
||||
|
||||
/**
|
||||
* Process Docker inventory cleanup job
|
||||
*/
|
||||
async process(_job) {
|
||||
const startTime = Date.now();
|
||||
console.log("🧹 Starting Docker inventory cleanup...");
|
||||
|
||||
try {
|
||||
// Step 1: Find and delete orphaned containers (containers for non-existent hosts)
|
||||
const orphanedContainers = await prisma.docker_containers.findMany({
|
||||
where: {
|
||||
host_id: {
|
||||
// Find containers where the host doesn't exist
|
||||
notIn: await prisma.hosts
|
||||
.findMany({ select: { id: true } })
|
||||
.then((hosts) => hosts.map((h) => h.id)),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let deletedContainersCount = 0;
|
||||
const deletedContainers = [];
|
||||
|
||||
for (const container of orphanedContainers) {
|
||||
try {
|
||||
await prisma.docker_containers.delete({
|
||||
where: { id: container.id },
|
||||
});
|
||||
deletedContainersCount++;
|
||||
deletedContainers.push({
|
||||
id: container.id,
|
||||
container_id: container.container_id,
|
||||
name: container.name,
|
||||
image_name: container.image_name,
|
||||
host_id: container.host_id,
|
||||
});
|
||||
console.log(
|
||||
`🗑️ Deleted orphaned container: ${container.name} (host_id: ${container.host_id})`,
|
||||
);
|
||||
} catch (deleteError) {
|
||||
console.error(
|
||||
`❌ Failed to delete container ${container.id}:`,
|
||||
deleteError.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Find and delete orphaned images (images with no containers using them)
|
||||
const orphanedImages = await prisma.docker_images.findMany({
|
||||
where: {
|
||||
docker_containers: {
|
||||
none: {},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
docker_containers: true,
|
||||
docker_image_updates: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let deletedImagesCount = 0;
|
||||
const deletedImages = [];
|
||||
|
||||
for (const image of orphanedImages) {
|
||||
try {
|
||||
// First delete any image updates associated with this image
|
||||
if (image._count.docker_image_updates > 0) {
|
||||
await prisma.docker_image_updates.deleteMany({
|
||||
where: { image_id: image.id },
|
||||
});
|
||||
}
|
||||
|
||||
// Then delete the image itself
|
||||
await prisma.docker_images.delete({
|
||||
where: { id: image.id },
|
||||
});
|
||||
deletedImagesCount++;
|
||||
deletedImages.push({
|
||||
id: image.id,
|
||||
repository: image.repository,
|
||||
tag: image.tag,
|
||||
image_id: image.image_id,
|
||||
});
|
||||
console.log(
|
||||
`🗑️ Deleted orphaned image: ${image.repository}:${image.tag}`,
|
||||
);
|
||||
} catch (deleteError) {
|
||||
console.error(
|
||||
`❌ Failed to delete image ${image.id}:`,
|
||||
deleteError.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
console.log(
|
||||
`✅ Docker inventory cleanup completed in ${executionTime}ms - Deleted ${deletedContainersCount} containers and ${deletedImagesCount} images`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedContainersCount,
|
||||
deletedImagesCount,
|
||||
deletedContainers,
|
||||
deletedImages,
|
||||
executionTime,
|
||||
};
|
||||
} catch (error) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
console.error(
|
||||
`❌ Docker inventory cleanup failed after ${executionTime}ms:`,
|
||||
error.message,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule recurring Docker inventory cleanup (daily at 4 AM)
|
||||
*/
|
||||
async schedule() {
|
||||
const job = await this.queueManager.queues[this.queueName].add(
|
||||
"docker-inventory-cleanup",
|
||||
{},
|
||||
{
|
||||
repeat: { cron: "0 4 * * *" }, // Daily at 4 AM
|
||||
jobId: "docker-inventory-cleanup-recurring",
|
||||
},
|
||||
);
|
||||
console.log("✅ Docker inventory cleanup scheduled");
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger manual Docker inventory cleanup
|
||||
*/
|
||||
async triggerManual() {
|
||||
const job = await this.queueManager.queues[this.queueName].add(
|
||||
"docker-inventory-cleanup-manual",
|
||||
{},
|
||||
{ priority: 1 },
|
||||
);
|
||||
console.log("✅ Manual Docker inventory cleanup triggered");
|
||||
return job;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DockerInventoryCleanup;
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Echo Hello Automation
|
||||
* Simple test automation task
|
||||
*/
|
||||
class EchoHello {
|
||||
constructor(queueManager) {
|
||||
this.queueManager = queueManager;
|
||||
this.queueName = "echo-hello";
|
||||
}
|
||||
|
||||
/**
|
||||
* Process echo hello job
|
||||
*/
|
||||
async process(job) {
|
||||
const startTime = Date.now();
|
||||
console.log("👋 Starting echo hello task...");
|
||||
|
||||
try {
|
||||
// Simple echo task
|
||||
const message = job.data.message || "Hello from BullMQ!";
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Simulate some work
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
console.log(`✅ Echo hello completed in ${executionTime}ms: ${message}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message,
|
||||
timestamp,
|
||||
executionTime,
|
||||
};
|
||||
} catch (error) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
console.error(
|
||||
`❌ Echo hello failed after ${executionTime}ms:`,
|
||||
error.message,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Echo hello is manual only - no scheduling
|
||||
*/
|
||||
async schedule() {
|
||||
console.log("ℹ️ Echo hello is manual only - no scheduling needed");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger manual echo hello
|
||||
*/
|
||||
async triggerManual(message = "Hello from BullMQ!") {
|
||||
const job = await this.queueManager.queues[this.queueName].add(
|
||||
"echo-hello-manual",
|
||||
{ message },
|
||||
{ priority: 1 },
|
||||
);
|
||||
console.log("✅ Manual echo hello triggered");
|
||||
return job;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EchoHello;
|
||||
@@ -14,14 +14,14 @@ class GitHubUpdateCheck {
|
||||
/**
|
||||
* Process GitHub update check job
|
||||
*/
|
||||
async process(job) {
|
||||
async process(_job) {
|
||||
const startTime = Date.now();
|
||||
console.log("🔍 Starting GitHub update check...");
|
||||
|
||||
try {
|
||||
// Get settings
|
||||
const settings = await prisma.settings.findFirst();
|
||||
const DEFAULT_GITHUB_REPO = "https://github.com/patchMon/patchmon";
|
||||
const DEFAULT_GITHUB_REPO = "https://github.com/PatchMon/PatchMon.git";
|
||||
const repoUrl = settings?.githubRepoUrl || DEFAULT_GITHUB_REPO;
|
||||
let owner, repo;
|
||||
|
||||
@@ -52,17 +52,24 @@ class GitHubUpdateCheck {
|
||||
}
|
||||
|
||||
// Read version from package.json
|
||||
let currentVersion = "1.2.7"; // fallback
|
||||
let currentVersion = null;
|
||||
try {
|
||||
const packageJson = require("../../../package.json");
|
||||
if (packageJson?.version) {
|
||||
currentVersion = packageJson.version;
|
||||
}
|
||||
} catch (packageError) {
|
||||
console.warn(
|
||||
console.error(
|
||||
"Could not read version from package.json:",
|
||||
packageError.message,
|
||||
);
|
||||
throw new Error(
|
||||
"Could not determine current version from package.json",
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentVersion) {
|
||||
throw new Error("Version not found in package.json");
|
||||
}
|
||||
|
||||
const isUpdateAvailable =
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
const { Queue, Worker } = require("bullmq");
|
||||
const { redis, redisConnection } = require("./shared/redis");
|
||||
const { prisma } = require("./shared/prisma");
|
||||
const agentWs = require("../agentWs");
|
||||
|
||||
// Import automation classes
|
||||
const GitHubUpdateCheck = require("./githubUpdateCheck");
|
||||
const SessionCleanup = require("./sessionCleanup");
|
||||
const OrphanedRepoCleanup = require("./orphanedRepoCleanup");
|
||||
const EchoHello = require("./echoHello");
|
||||
const OrphanedPackageCleanup = require("./orphanedPackageCleanup");
|
||||
const DockerInventoryCleanup = require("./dockerInventoryCleanup");
|
||||
const MetricsReporting = require("./metricsReporting");
|
||||
|
||||
// Queue names
|
||||
const QUEUE_NAMES = {
|
||||
GITHUB_UPDATE_CHECK: "github-update-check",
|
||||
SESSION_CLEANUP: "session-cleanup",
|
||||
SYSTEM_MAINTENANCE: "system-maintenance",
|
||||
ECHO_HELLO: "echo-hello",
|
||||
ORPHANED_REPO_CLEANUP: "orphaned-repo-cleanup",
|
||||
ORPHANED_PACKAGE_CLEANUP: "orphaned-package-cleanup",
|
||||
DOCKER_INVENTORY_CLEANUP: "docker-inventory-cleanup",
|
||||
METRICS_REPORTING: "metrics-reporting",
|
||||
AGENT_COMMANDS: "agent-commands",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -60,7 +65,7 @@ class QueueManager {
|
||||
* Initialize all queues
|
||||
*/
|
||||
async initializeQueues() {
|
||||
for (const [key, queueName] of Object.entries(QUEUE_NAMES)) {
|
||||
for (const [_key, queueName] of Object.entries(QUEUE_NAMES)) {
|
||||
this.queues[queueName] = new Queue(queueName, {
|
||||
connection: redisConnection,
|
||||
defaultJobOptions: {
|
||||
@@ -88,7 +93,13 @@ class QueueManager {
|
||||
this.automations[QUEUE_NAMES.SESSION_CLEANUP] = new SessionCleanup(this);
|
||||
this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP] =
|
||||
new OrphanedRepoCleanup(this);
|
||||
this.automations[QUEUE_NAMES.ECHO_HELLO] = new EchoHello(this);
|
||||
this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP] =
|
||||
new OrphanedPackageCleanup(this);
|
||||
this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP] =
|
||||
new DockerInventoryCleanup(this);
|
||||
this.automations[QUEUE_NAMES.METRICS_REPORTING] = new MetricsReporting(
|
||||
this,
|
||||
);
|
||||
|
||||
console.log("✅ All automation classes initialized");
|
||||
}
|
||||
@@ -97,16 +108,27 @@ class QueueManager {
|
||||
* Initialize all workers
|
||||
*/
|
||||
async initializeWorkers() {
|
||||
// Optimized worker options to reduce Redis connections
|
||||
const workerOptions = {
|
||||
connection: redisConnection,
|
||||
concurrency: 1, // Keep concurrency low to reduce connections
|
||||
// Connection optimization
|
||||
maxStalledCount: 1,
|
||||
stalledInterval: 30000,
|
||||
// Reduce connection churn
|
||||
settings: {
|
||||
stalledInterval: 30000,
|
||||
maxStalledCount: 1,
|
||||
},
|
||||
};
|
||||
|
||||
// GitHub Update Check Worker
|
||||
this.workers[QUEUE_NAMES.GITHUB_UPDATE_CHECK] = new Worker(
|
||||
QUEUE_NAMES.GITHUB_UPDATE_CHECK,
|
||||
this.automations[QUEUE_NAMES.GITHUB_UPDATE_CHECK].process.bind(
|
||||
this.automations[QUEUE_NAMES.GITHUB_UPDATE_CHECK],
|
||||
),
|
||||
{
|
||||
connection: redisConnection,
|
||||
concurrency: 1,
|
||||
},
|
||||
workerOptions,
|
||||
);
|
||||
|
||||
// Session Cleanup Worker
|
||||
@@ -115,10 +137,7 @@ class QueueManager {
|
||||
this.automations[QUEUE_NAMES.SESSION_CLEANUP].process.bind(
|
||||
this.automations[QUEUE_NAMES.SESSION_CLEANUP],
|
||||
),
|
||||
{
|
||||
connection: redisConnection,
|
||||
concurrency: 1,
|
||||
},
|
||||
workerOptions,
|
||||
);
|
||||
|
||||
// Orphaned Repo Cleanup Worker
|
||||
@@ -127,32 +146,60 @@ class QueueManager {
|
||||
this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].process.bind(
|
||||
this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP],
|
||||
),
|
||||
{
|
||||
connection: redisConnection,
|
||||
concurrency: 1,
|
||||
},
|
||||
workerOptions,
|
||||
);
|
||||
|
||||
// Echo Hello Worker
|
||||
this.workers[QUEUE_NAMES.ECHO_HELLO] = new Worker(
|
||||
QUEUE_NAMES.ECHO_HELLO,
|
||||
this.automations[QUEUE_NAMES.ECHO_HELLO].process.bind(
|
||||
this.automations[QUEUE_NAMES.ECHO_HELLO],
|
||||
// Orphaned Package Cleanup Worker
|
||||
this.workers[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP] = new Worker(
|
||||
QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP,
|
||||
this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].process.bind(
|
||||
this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP],
|
||||
),
|
||||
{
|
||||
connection: redisConnection,
|
||||
concurrency: 1,
|
||||
},
|
||||
workerOptions,
|
||||
);
|
||||
|
||||
// Add error handling for all workers
|
||||
Object.values(this.workers).forEach((worker) => {
|
||||
worker.on("error", (error) => {
|
||||
console.error("Worker error:", error);
|
||||
});
|
||||
});
|
||||
// Docker Inventory Cleanup Worker
|
||||
this.workers[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP] = new Worker(
|
||||
QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP,
|
||||
this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].process.bind(
|
||||
this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP],
|
||||
),
|
||||
workerOptions,
|
||||
);
|
||||
|
||||
console.log("✅ All workers initialized");
|
||||
// Metrics Reporting Worker
|
||||
this.workers[QUEUE_NAMES.METRICS_REPORTING] = new Worker(
|
||||
QUEUE_NAMES.METRICS_REPORTING,
|
||||
this.automations[QUEUE_NAMES.METRICS_REPORTING].process.bind(
|
||||
this.automations[QUEUE_NAMES.METRICS_REPORTING],
|
||||
),
|
||||
workerOptions,
|
||||
);
|
||||
|
||||
// Agent Commands Worker
|
||||
this.workers[QUEUE_NAMES.AGENT_COMMANDS] = new Worker(
|
||||
QUEUE_NAMES.AGENT_COMMANDS,
|
||||
async (job) => {
|
||||
const { api_id, type } = job.data;
|
||||
console.log(`Processing agent command: ${type} for ${api_id}`);
|
||||
|
||||
// Send command via WebSocket based on type
|
||||
if (type === "report_now") {
|
||||
agentWs.pushReportNow(api_id);
|
||||
} else if (type === "settings_update") {
|
||||
// For settings update, we need additional data
|
||||
const { update_interval } = job.data;
|
||||
agentWs.pushSettingsUpdate(api_id, update_interval);
|
||||
} else {
|
||||
console.error(`Unknown agent command type: ${type}`);
|
||||
}
|
||||
},
|
||||
workerOptions,
|
||||
);
|
||||
|
||||
console.log(
|
||||
"✅ All workers initialized with optimized connection settings",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,7 +231,9 @@ class QueueManager {
|
||||
await this.automations[QUEUE_NAMES.GITHUB_UPDATE_CHECK].schedule();
|
||||
await this.automations[QUEUE_NAMES.SESSION_CLEANUP].schedule();
|
||||
await this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].schedule();
|
||||
await this.automations[QUEUE_NAMES.ECHO_HELLO].schedule();
|
||||
await this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].schedule();
|
||||
await this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].schedule();
|
||||
await this.automations[QUEUE_NAMES.METRICS_REPORTING].schedule();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,8 +251,20 @@ class QueueManager {
|
||||
return this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].triggerManual();
|
||||
}
|
||||
|
||||
async triggerEchoHello(message = "Hello from BullMQ!") {
|
||||
return this.automations[QUEUE_NAMES.ECHO_HELLO].triggerManual(message);
|
||||
async triggerOrphanedPackageCleanup() {
|
||||
return this.automations[
|
||||
QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP
|
||||
].triggerManual();
|
||||
}
|
||||
|
||||
async triggerDockerInventoryCleanup() {
|
||||
return this.automations[
|
||||
QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP
|
||||
].triggerManual();
|
||||
}
|
||||
|
||||
async triggerMetricsReporting() {
|
||||
return this.automations[QUEUE_NAMES.METRICS_REPORTING].triggerManual();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -262,6 +323,73 @@ class QueueManager {
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get jobs for a specific host (by API ID)
|
||||
*/
|
||||
async getHostJobs(apiId, limit = 20) {
|
||||
const queue = this.queues[QUEUE_NAMES.AGENT_COMMANDS];
|
||||
if (!queue) {
|
||||
throw new Error(`Queue ${QUEUE_NAMES.AGENT_COMMANDS} not found`);
|
||||
}
|
||||
|
||||
console.log(`[getHostJobs] Looking for jobs with api_id: ${apiId}`);
|
||||
|
||||
// Get active queue status (waiting, active, delayed, failed)
|
||||
const [waiting, active, delayed, failed] = await Promise.all([
|
||||
queue.getWaiting(),
|
||||
queue.getActive(),
|
||||
queue.getDelayed(),
|
||||
queue.getFailed(),
|
||||
]);
|
||||
|
||||
// Filter by API ID
|
||||
const filterByApiId = (jobs) =>
|
||||
jobs.filter((job) => job.data && job.data.api_id === apiId);
|
||||
|
||||
const waitingCount = filterByApiId(waiting).length;
|
||||
const activeCount = filterByApiId(active).length;
|
||||
const delayedCount = filterByApiId(delayed).length;
|
||||
const failedCount = filterByApiId(failed).length;
|
||||
|
||||
console.log(
|
||||
`[getHostJobs] Queue status - Waiting: ${waitingCount}, Active: ${activeCount}, Delayed: ${delayedCount}, Failed: ${failedCount}`,
|
||||
);
|
||||
|
||||
// Get job history from database (shows all attempts and status changes)
|
||||
const jobHistory = await prisma.job_history.findMany({
|
||||
where: {
|
||||
api_id: apiId,
|
||||
},
|
||||
orderBy: {
|
||||
created_at: "desc",
|
||||
},
|
||||
take: limit,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[getHostJobs] Found ${jobHistory.length} job history records for api_id: ${apiId}`,
|
||||
);
|
||||
|
||||
return {
|
||||
waiting: waitingCount,
|
||||
active: activeCount,
|
||||
delayed: delayedCount,
|
||||
failed: failedCount,
|
||||
jobHistory: jobHistory.map((job) => ({
|
||||
id: job.id,
|
||||
job_id: job.job_id,
|
||||
job_name: job.job_name,
|
||||
status: job.status,
|
||||
attempt_number: job.attempt_number,
|
||||
error_message: job.error_message,
|
||||
output: job.output,
|
||||
created_at: job.created_at,
|
||||
updated_at: job.updated_at,
|
||||
completed_at: job.completed_at,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown
|
||||
*/
|
||||
@@ -269,8 +397,24 @@ class QueueManager {
|
||||
console.log("🛑 Shutting down queue manager...");
|
||||
|
||||
for (const queueName of Object.keys(this.queues)) {
|
||||
await this.queues[queueName].close();
|
||||
await this.workers[queueName].close();
|
||||
try {
|
||||
await this.queues[queueName].close();
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`⚠️ Failed to close queue '${queueName}':`,
|
||||
e?.message || e,
|
||||
);
|
||||
}
|
||||
if (this.workers?.[queueName]) {
|
||||
try {
|
||||
await this.workers[queueName].close();
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`⚠️ Failed to close worker for '${queueName}':`,
|
||||
e?.message || e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await redis.quit();
|
||||
|
||||
172
backend/src/services/automation/metricsReporting.js
Normal file
172
backend/src/services/automation/metricsReporting.js
Normal file
@@ -0,0 +1,172 @@
|
||||
const axios = require("axios");
|
||||
const { prisma } = require("./shared/prisma");
|
||||
const { updateSettings } = require("../../services/settingsService");
|
||||
|
||||
const METRICS_API_URL =
|
||||
process.env.METRICS_API_URL || "https://metrics.patchmon.cloud";
|
||||
|
||||
/**
|
||||
* Metrics Reporting Automation
|
||||
* Sends anonymous usage metrics every 24 hours
|
||||
*/
|
||||
class MetricsReporting {
|
||||
constructor(queueManager) {
|
||||
this.queueManager = queueManager;
|
||||
this.queueName = "metrics-reporting";
|
||||
}
|
||||
|
||||
/**
|
||||
* Process metrics reporting job
|
||||
*/
|
||||
async process(_job, silent = false) {
|
||||
const startTime = Date.now();
|
||||
if (!silent) console.log("📊 Starting metrics reporting...");
|
||||
|
||||
try {
|
||||
// Fetch fresh settings directly from database (bypass cache)
|
||||
const settings = await prisma.settings.findFirst({
|
||||
orderBy: { updated_at: "desc" },
|
||||
});
|
||||
|
||||
// Check if metrics are enabled
|
||||
if (settings.metrics_enabled !== true) {
|
||||
if (!silent) console.log("📊 Metrics reporting is disabled");
|
||||
return { success: false, reason: "disabled" };
|
||||
}
|
||||
|
||||
// Check if we have an anonymous ID
|
||||
if (!settings.metrics_anonymous_id) {
|
||||
if (!silent) console.log("📊 No anonymous ID found, skipping metrics");
|
||||
return { success: false, reason: "no_id" };
|
||||
}
|
||||
|
||||
// Get host count
|
||||
const hostCount = await prisma.hosts.count();
|
||||
|
||||
// Get version
|
||||
const packageJson = require("../../../package.json");
|
||||
const version = packageJson.version;
|
||||
|
||||
// Prepare metrics data
|
||||
const metricsData = {
|
||||
anonymous_id: settings.metrics_anonymous_id,
|
||||
host_count: hostCount,
|
||||
version,
|
||||
};
|
||||
|
||||
if (!silent)
|
||||
console.log(
|
||||
`📊 Sending metrics: ${hostCount} hosts, version ${version}`,
|
||||
);
|
||||
|
||||
// Send to metrics API
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${METRICS_API_URL}/metrics/submit`,
|
||||
metricsData,
|
||||
{
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Update last sent timestamp
|
||||
await updateSettings(settings.id, {
|
||||
metrics_last_sent: new Date(),
|
||||
});
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
if (!silent)
|
||||
console.log(
|
||||
`✅ Metrics sent successfully in ${executionTime}ms:`,
|
||||
response.data,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data,
|
||||
hostCount,
|
||||
version,
|
||||
executionTime,
|
||||
};
|
||||
} catch (apiError) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
if (!silent)
|
||||
console.error(
|
||||
`❌ Failed to send metrics to API after ${executionTime}ms:`,
|
||||
apiError.message,
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
reason: "api_error",
|
||||
error: apiError.message,
|
||||
executionTime,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
if (!silent)
|
||||
console.error(
|
||||
`❌ Error in metrics reporting after ${executionTime}ms:`,
|
||||
error.message,
|
||||
);
|
||||
// Don't throw on silent mode, just return failure
|
||||
if (silent) {
|
||||
return {
|
||||
success: false,
|
||||
reason: "error",
|
||||
error: error.message,
|
||||
executionTime,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule recurring metrics reporting (daily at 2 AM)
|
||||
*/
|
||||
async schedule() {
|
||||
const job = await this.queueManager.queues[this.queueName].add(
|
||||
"metrics-reporting",
|
||||
{},
|
||||
{
|
||||
repeat: { cron: "0 2 * * *" }, // Daily at 2 AM
|
||||
jobId: "metrics-reporting-recurring",
|
||||
},
|
||||
);
|
||||
console.log("✅ Metrics reporting scheduled (daily at 2 AM)");
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger manual metrics reporting
|
||||
*/
|
||||
async triggerManual() {
|
||||
const job = await this.queueManager.queues[this.queueName].add(
|
||||
"metrics-reporting-manual",
|
||||
{},
|
||||
{ priority: 1 },
|
||||
);
|
||||
console.log("✅ Manual metrics reporting triggered");
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send metrics immediately (silent mode)
|
||||
* Used for automatic sending on server startup
|
||||
*/
|
||||
async sendSilent() {
|
||||
try {
|
||||
const result = await this.process({ name: "startup-silent" }, true);
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Silent failure on startup
|
||||
return { success: false, reason: "error", error: error.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MetricsReporting;
|
||||
116
backend/src/services/automation/orphanedPackageCleanup.js
Normal file
116
backend/src/services/automation/orphanedPackageCleanup.js
Normal file
@@ -0,0 +1,116 @@
|
||||
const { prisma } = require("./shared/prisma");
|
||||
|
||||
/**
|
||||
* Orphaned Package Cleanup Automation
|
||||
* Removes packages with no associated hosts
|
||||
*/
|
||||
class OrphanedPackageCleanup {
|
||||
constructor(queueManager) {
|
||||
this.queueManager = queueManager;
|
||||
this.queueName = "orphaned-package-cleanup";
|
||||
}
|
||||
|
||||
/**
|
||||
* Process orphaned package cleanup job
|
||||
*/
|
||||
async process(_job) {
|
||||
const startTime = Date.now();
|
||||
console.log("🧹 Starting orphaned package cleanup...");
|
||||
|
||||
try {
|
||||
// Find packages with 0 hosts
|
||||
const orphanedPackages = await prisma.packages.findMany({
|
||||
where: {
|
||||
host_packages: {
|
||||
none: {},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
host_packages: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let deletedCount = 0;
|
||||
const deletedPackages = [];
|
||||
|
||||
// Delete orphaned packages
|
||||
for (const pkg of orphanedPackages) {
|
||||
try {
|
||||
await prisma.packages.delete({
|
||||
where: { id: pkg.id },
|
||||
});
|
||||
deletedCount++;
|
||||
deletedPackages.push({
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
description: pkg.description,
|
||||
category: pkg.category,
|
||||
latest_version: pkg.latest_version,
|
||||
});
|
||||
console.log(
|
||||
`🗑️ Deleted orphaned package: ${pkg.name} (${pkg.latest_version})`,
|
||||
);
|
||||
} catch (deleteError) {
|
||||
console.error(
|
||||
`❌ Failed to delete package ${pkg.id}:`,
|
||||
deleteError.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
console.log(
|
||||
`✅ Orphaned package cleanup completed in ${executionTime}ms - Deleted ${deletedCount} packages`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedCount,
|
||||
deletedPackages,
|
||||
executionTime,
|
||||
};
|
||||
} catch (error) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
console.error(
|
||||
`❌ Orphaned package cleanup failed after ${executionTime}ms:`,
|
||||
error.message,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule recurring orphaned package cleanup (daily at 3 AM)
|
||||
*/
|
||||
async schedule() {
|
||||
const job = await this.queueManager.queues[this.queueName].add(
|
||||
"orphaned-package-cleanup",
|
||||
{},
|
||||
{
|
||||
repeat: { cron: "0 3 * * *" }, // Daily at 3 AM
|
||||
jobId: "orphaned-package-cleanup-recurring",
|
||||
},
|
||||
);
|
||||
console.log("✅ Orphaned package cleanup scheduled");
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger manual orphaned package cleanup
|
||||
*/
|
||||
async triggerManual() {
|
||||
const job = await this.queueManager.queues[this.queueName].add(
|
||||
"orphaned-package-cleanup-manual",
|
||||
{},
|
||||
{ priority: 1 },
|
||||
);
|
||||
console.log("✅ Manual orphaned package cleanup triggered");
|
||||
return job;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OrphanedPackageCleanup;
|
||||
@@ -13,7 +13,7 @@ class OrphanedRepoCleanup {
|
||||
/**
|
||||
* Process orphaned repository cleanup job
|
||||
*/
|
||||
async process(job) {
|
||||
async process(_job) {
|
||||
const startTime = Date.now();
|
||||
console.log("🧹 Starting orphaned repository cleanup...");
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const { prisma } = require("./shared/prisma");
|
||||
const { cleanup_expired_sessions } = require("../../utils/session_manager");
|
||||
|
||||
/**
|
||||
* Session Cleanup Automation
|
||||
@@ -14,7 +13,7 @@ class SessionCleanup {
|
||||
/**
|
||||
* Process session cleanup job
|
||||
*/
|
||||
async process(job) {
|
||||
async process(_job) {
|
||||
const startTime = Date.now();
|
||||
console.log("🧹 Starting session cleanup...");
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { getPrismaClient } = require("../../../config/prisma");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
module.exports = { prisma };
|
||||
|
||||
@@ -1,16 +1,56 @@
|
||||
const IORedis = require("ioredis");
|
||||
|
||||
// Redis connection configuration
|
||||
// Redis connection configuration with connection pooling
|
||||
const redisConnection = {
|
||||
host: process.env.REDIS_HOST || "localhost",
|
||||
port: parseInt(process.env.REDIS_PORT) || 6379,
|
||||
port: parseInt(process.env.REDIS_PORT, 10) || 6379,
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
db: parseInt(process.env.REDIS_DB) || 0,
|
||||
username: process.env.REDIS_USER || undefined,
|
||||
db: parseInt(process.env.REDIS_DB, 10) || 0,
|
||||
// Connection pooling settings
|
||||
lazyConnect: true,
|
||||
keepAlive: 30000,
|
||||
connectTimeout: 30000, // Increased from 10s to 30s
|
||||
commandTimeout: 30000, // Increased from 5s to 30s
|
||||
enableReadyCheck: false,
|
||||
// Reduce connection churn
|
||||
family: 4, // Force IPv4
|
||||
// Retry settings
|
||||
retryDelayOnClusterDown: 300,
|
||||
retryDelayOnFailover: 100,
|
||||
maxRetriesPerRequest: null, // BullMQ requires this to be null
|
||||
// Connection pool settings
|
||||
maxLoadingTimeout: 30000,
|
||||
};
|
||||
|
||||
// Create Redis connection
|
||||
const redis = new IORedis(redisConnection);
|
||||
// Create Redis connection with singleton pattern
|
||||
let redisInstance = null;
|
||||
|
||||
module.exports = { redis, redisConnection };
|
||||
function getRedisConnection() {
|
||||
if (!redisInstance) {
|
||||
redisInstance = new IORedis(redisConnection);
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on("beforeExit", async () => {
|
||||
await redisInstance.quit();
|
||||
});
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
await redisInstance.quit();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
await redisInstance.quit();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
return redisInstance;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
redis: getRedisConnection(),
|
||||
redisConnection,
|
||||
getRedisConnection,
|
||||
};
|
||||
|
||||
@@ -33,7 +33,8 @@ async function checkPublicRepo(owner, repo) {
|
||||
try {
|
||||
const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
|
||||
|
||||
let currentVersion = "1.2.7"; // fallback
|
||||
// Get current version for User-Agent (or use generic if unavailable)
|
||||
let currentVersion = "unknown";
|
||||
try {
|
||||
const packageJson = require("../../../package.json");
|
||||
if (packageJson?.version) {
|
||||
@@ -41,7 +42,7 @@ async function checkPublicRepo(owner, repo) {
|
||||
}
|
||||
} catch (packageError) {
|
||||
console.warn(
|
||||
"Could not read version from package.json for User-Agent, using fallback:",
|
||||
"Could not read version from package.json for User-Agent:",
|
||||
packageError.message,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Cached settings instance
|
||||
let cachedSettings = null;
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { exec } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
class UpdateScheduler {
|
||||
constructor() {
|
||||
this.isRunning = false;
|
||||
this.intervalId = null;
|
||||
this.checkInterval = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
|
||||
}
|
||||
|
||||
// Start the scheduler
|
||||
start() {
|
||||
if (this.isRunning) {
|
||||
console.log("Update scheduler is already running");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("🔄 Starting update scheduler...");
|
||||
this.isRunning = true;
|
||||
|
||||
// Run initial check
|
||||
this.checkForUpdates();
|
||||
|
||||
// Schedule regular checks
|
||||
this.intervalId = setInterval(() => {
|
||||
this.checkForUpdates();
|
||||
}, this.checkInterval);
|
||||
|
||||
console.log(
|
||||
`✅ Update scheduler started - checking every ${this.checkInterval / (60 * 60 * 1000)} hours`,
|
||||
);
|
||||
}
|
||||
|
||||
// Stop the scheduler
|
||||
stop() {
|
||||
if (!this.isRunning) {
|
||||
console.log("Update scheduler is not running");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("🛑 Stopping update scheduler...");
|
||||
this.isRunning = false;
|
||||
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
console.log("✅ Update scheduler stopped");
|
||||
}
|
||||
|
||||
// Check for updates
|
||||
async checkForUpdates() {
|
||||
try {
|
||||
console.log("🔍 Checking for updates...");
|
||||
|
||||
// Get settings
|
||||
const settings = await prisma.settings.findFirst();
|
||||
const DEFAULT_GITHUB_REPO = "https://github.com/patchMon/patchmon";
|
||||
const repoUrl = settings?.githubRepoUrl || DEFAULT_GITHUB_REPO;
|
||||
let owner, repo;
|
||||
|
||||
if (repoUrl.includes("git@github.com:")) {
|
||||
const match = repoUrl.match(/git@github\.com:([^/]+)\/([^/]+)\.git/);
|
||||
if (match) {
|
||||
[, owner, repo] = match;
|
||||
}
|
||||
} else if (repoUrl.includes("github.com/")) {
|
||||
const match = repoUrl.match(
|
||||
/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/,
|
||||
);
|
||||
if (match) {
|
||||
[, owner, repo] = match;
|
||||
}
|
||||
}
|
||||
|
||||
if (!owner || !repo) {
|
||||
console.log(
|
||||
"⚠️ Could not parse GitHub repository URL, skipping update check",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let latestVersion;
|
||||
const isPrivate = settings.repositoryType === "private";
|
||||
|
||||
if (isPrivate) {
|
||||
// Use SSH for private repositories
|
||||
latestVersion = await this.checkPrivateRepo(settings, owner, repo);
|
||||
} else {
|
||||
// Use GitHub API for public repositories
|
||||
latestVersion = await this.checkPublicRepo(owner, repo);
|
||||
}
|
||||
|
||||
if (!latestVersion) {
|
||||
console.log(
|
||||
"⚠️ Could not determine latest version, skipping update check",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read version from package.json dynamically
|
||||
let currentVersion = "1.2.9"; // fallback
|
||||
try {
|
||||
const packageJson = require("../../package.json");
|
||||
if (packageJson?.version) {
|
||||
currentVersion = packageJson.version;
|
||||
}
|
||||
} catch (packageError) {
|
||||
console.warn(
|
||||
"Could not read version from package.json, using fallback:",
|
||||
packageError.message,
|
||||
);
|
||||
}
|
||||
const isUpdateAvailable =
|
||||
this.compareVersions(latestVersion, currentVersion) > 0;
|
||||
|
||||
// Update settings with check results
|
||||
await prisma.settings.update({
|
||||
where: { id: settings.id },
|
||||
data: {
|
||||
last_update_check: new Date(),
|
||||
update_available: isUpdateAvailable,
|
||||
latest_version: latestVersion,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`✅ Update check completed - Current: ${currentVersion}, Latest: ${latestVersion}, Update Available: ${isUpdateAvailable}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("❌ Error checking for updates:", error.message);
|
||||
|
||||
// Update last check time even on error
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
if (settings) {
|
||||
await prisma.settings.update({
|
||||
where: { id: settings.id },
|
||||
data: {
|
||||
last_update_check: new Date(),
|
||||
update_available: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (updateError) {
|
||||
console.error(
|
||||
"❌ Error updating last check time:",
|
||||
updateError.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check private repository using SSH
|
||||
async checkPrivateRepo(settings, owner, repo) {
|
||||
try {
|
||||
let sshKeyPath = settings.sshKeyPath;
|
||||
|
||||
// Try to find SSH key if not configured
|
||||
if (!sshKeyPath) {
|
||||
const possibleKeyPaths = [
|
||||
"/root/.ssh/id_ed25519",
|
||||
"/root/.ssh/id_rsa",
|
||||
"/home/patchmon/.ssh/id_ed25519",
|
||||
"/home/patchmon/.ssh/id_rsa",
|
||||
"/var/www/.ssh/id_ed25519",
|
||||
"/var/www/.ssh/id_rsa",
|
||||
];
|
||||
|
||||
for (const path of possibleKeyPaths) {
|
||||
try {
|
||||
require("node:fs").accessSync(path);
|
||||
sshKeyPath = path;
|
||||
break;
|
||||
} catch {
|
||||
// Key not found at this path, try next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sshKeyPath) {
|
||||
throw new Error("No SSH deploy key found");
|
||||
}
|
||||
|
||||
const sshRepoUrl = `git@github.com:${owner}/${repo}.git`;
|
||||
const env = {
|
||||
...process.env,
|
||||
GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes`,
|
||||
};
|
||||
|
||||
const { stdout: sshLatestTag } = await execAsync(
|
||||
`git ls-remote --tags --sort=-version:refname ${sshRepoUrl} | head -n 1 | sed 's/.*refs\\/tags\\///' | sed 's/\\^{}//'`,
|
||||
{
|
||||
timeout: 10000,
|
||||
env: env,
|
||||
},
|
||||
);
|
||||
|
||||
return sshLatestTag.trim().replace("v", "");
|
||||
} catch (error) {
|
||||
console.error("SSH Git error:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Check public repository using GitHub API
|
||||
async checkPublicRepo(owner, repo) {
|
||||
try {
|
||||
const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
|
||||
|
||||
// Get current version for User-Agent
|
||||
let currentVersion = "1.2.9"; // fallback
|
||||
try {
|
||||
const packageJson = require("../../package.json");
|
||||
if (packageJson?.version) {
|
||||
currentVersion = packageJson.version;
|
||||
}
|
||||
} catch (packageError) {
|
||||
console.warn(
|
||||
"Could not read version from package.json for User-Agent, using fallback:",
|
||||
packageError.message,
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(httpsRepoUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": `PatchMon-Server/${currentVersion}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
if (
|
||||
errorText.includes("rate limit") ||
|
||||
errorText.includes("API rate limit")
|
||||
) {
|
||||
console.log(
|
||||
"⚠️ GitHub API rate limit exceeded, skipping update check",
|
||||
);
|
||||
return null; // Return null instead of throwing error
|
||||
}
|
||||
throw new Error(
|
||||
`GitHub API error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const releaseData = await response.json();
|
||||
return releaseData.tag_name.replace("v", "");
|
||||
} catch (error) {
|
||||
console.error("GitHub API error:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Compare version strings (semantic versioning)
|
||||
compareVersions(version1, version2) {
|
||||
const v1parts = version1.split(".").map(Number);
|
||||
const v2parts = version2.split(".").map(Number);
|
||||
|
||||
const maxLength = Math.max(v1parts.length, v2parts.length);
|
||||
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const v1part = v1parts[i] || 0;
|
||||
const v2part = v2parts[i] || 0;
|
||||
|
||||
if (v1part > v2part) return 1;
|
||||
if (v1part < v2part) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get scheduler status
|
||||
getStatus() {
|
||||
return {
|
||||
isRunning: this.isRunning,
|
||||
checkInterval: this.checkInterval,
|
||||
nextCheck: this.isRunning
|
||||
? new Date(Date.now() + this.checkInterval)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const updateScheduler = new UpdateScheduler();
|
||||
|
||||
module.exports = updateScheduler;
|
||||
@@ -1,8 +1,8 @@
|
||||
const jwt = require("jsonwebtoken");
|
||||
const crypto = require("node:crypto");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
/**
|
||||
* Session Manager - Handles secure session management with inactivity timeout
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.0/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**", "!**/*.css"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
## Overview
|
||||
|
||||
PatchMon is a containerised application that monitors system patches and updates. The application consists of three main services:
|
||||
PatchMon is a containerised application that monitors system patches and updates. The application consists of four main services:
|
||||
|
||||
- **Database**: PostgreSQL 17
|
||||
- **Redis**: Redis 7 for BullMQ job queues and caching
|
||||
- **Backend**: Node.js API server
|
||||
- **Frontend**: React application served via NGINX
|
||||
|
||||
@@ -38,21 +39,31 @@ These tags are available for both backend and frontend images as they are versio
|
||||
environment:
|
||||
DATABASE_URL: postgresql://patchmon_user:REPLACE_YOUR_POSTGRES_PASSWORD_HERE@database:5432/patchmon_db
|
||||
```
|
||||
4. Generate a strong JWT secret. You can do this like so:
|
||||
4. Set a Redis password in the Redis service command where it says:
|
||||
```yaml
|
||||
command: redis-server --requirepass your-redis-password-here
|
||||
```
|
||||
Note: The Redis service uses a hardcoded password in the command line for better reliability and to avoid environment variable parsing issues.
|
||||
5. Update the corresponding `REDIS_PASSWORD` in the backend service where it says:
|
||||
```yaml
|
||||
environment:
|
||||
REDIS_PASSWORD: your-redis-password-here
|
||||
```
|
||||
6. Generate a strong JWT secret. You can do this like so:
|
||||
```bash
|
||||
openssl rand -hex 64
|
||||
```
|
||||
5. Set a JWT secret in the backend service where it says:
|
||||
7. Set a JWT secret in the backend service where it says:
|
||||
```yaml
|
||||
environment:
|
||||
JWT_SECRET: # CREATE A STRONG SECRET AND PUT IT HERE
|
||||
```
|
||||
6. Configure environment variables (see [Configuration](#configuration) section)
|
||||
7. Start the application:
|
||||
8. Configure environment variables (see [Configuration](#configuration) section)
|
||||
9. Start the application:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
8. Access the application at `http://localhost:3000`
|
||||
10. Access the application at `http://localhost:3000`
|
||||
|
||||
## Updating
|
||||
|
||||
@@ -106,6 +117,15 @@ When you do this, updating to a new version requires manually updating the image
|
||||
| `POSTGRES_USER` | Database user | `patchmon_user` |
|
||||
| `POSTGRES_PASSWORD` | Database password | **MUST BE SET!** |
|
||||
|
||||
#### Redis Service
|
||||
|
||||
| Variable | Description | Default |
|
||||
| -------------- | ------------------ | ---------------- |
|
||||
| `REDIS_PASSWORD` | Redis password | **MUST BE SET!** |
|
||||
|
||||
> [!NOTE]
|
||||
> The Redis service uses a hardcoded password in the command line (`redis-server --requirepass your-password`) instead of environment variables or configuration files. This approach eliminates parsing issues and provides better reliability. The password must be set in both the Redis command and the backend service environment variables.
|
||||
|
||||
#### Backend Service
|
||||
|
||||
##### Database Configuration
|
||||
@@ -116,6 +136,33 @@ When you do this, updating to a new version requires manually updating the image
|
||||
| `PM_DB_CONN_MAX_ATTEMPTS` | Maximum database connection attempts | `30` |
|
||||
| `PM_DB_CONN_WAIT_INTERVAL` | Wait interval between connection attempts in seconds | `2` |
|
||||
|
||||
##### Database Connection Pool Configuration (Prisma)
|
||||
|
||||
| Variable | Description | Default |
|
||||
| --------------------- | ---------------------------------------------------------- | ------- |
|
||||
| `DB_CONNECTION_LIMIT` | Maximum number of database connections per instance | `30` |
|
||||
| `DB_POOL_TIMEOUT` | Seconds to wait for an available connection before timeout | `20` |
|
||||
| `DB_CONNECT_TIMEOUT` | Seconds to wait for initial database connection | `10` |
|
||||
| `DB_IDLE_TIMEOUT` | Seconds before closing idle connections | `300` |
|
||||
| `DB_MAX_LIFETIME` | Maximum lifetime of a connection in seconds | `1800` |
|
||||
|
||||
> [!TIP]
|
||||
> The connection pool limit should be adjusted based on your deployment size:
|
||||
> - **Small deployment (1-10 hosts)**: `DB_CONNECTION_LIMIT=15` is sufficient
|
||||
> - **Medium deployment (10-50 hosts)**: `DB_CONNECTION_LIMIT=30` (default)
|
||||
> - **Large deployment (50+ hosts)**: `DB_CONNECTION_LIMIT=50` or higher
|
||||
>
|
||||
> Each connection pool serves one backend instance. If you have concurrent operations (multiple users, background jobs, agent checkins), increase the pool size accordingly.
|
||||
|
||||
##### Redis Configuration
|
||||
|
||||
| Variable | Description | Default |
|
||||
| --------------- | ------------------------------ | ------- |
|
||||
| `REDIS_HOST` | Redis server hostname | `redis` |
|
||||
| `REDIS_PORT` | Redis server port | `6379` |
|
||||
| `REDIS_PASSWORD` | Redis authentication password | **MUST BE UPDATED WITH YOUR REDIS_PASSWORD!** |
|
||||
| `REDIS_DB` | Redis database number | `0` |
|
||||
|
||||
##### Authentication & Security
|
||||
|
||||
| Variable | Description | Default |
|
||||
@@ -165,9 +212,10 @@ When you do this, updating to a new version requires manually updating the image
|
||||
|
||||
### Volumes
|
||||
|
||||
The compose file creates two Docker volumes:
|
||||
The compose file creates three Docker volumes:
|
||||
|
||||
* `postgres_data`: PostgreSQL's data directory.
|
||||
* `redis_data`: Redis's data directory.
|
||||
* `agent_files`: PatchMon's agent files.
|
||||
|
||||
If you wish to bind either if their respective container paths to a host path rather than a Docker volume, you can do so in the Docker Compose file.
|
||||
@@ -201,6 +249,7 @@ For development with live reload and source code mounting:
|
||||
- Frontend: `http://localhost:3000`
|
||||
- Backend API: `http://localhost:3001`
|
||||
- Database: `localhost:5432`
|
||||
- Redis: `localhost:6379`
|
||||
|
||||
## Development Docker Compose
|
||||
|
||||
@@ -254,6 +303,7 @@ docker compose -f docker/docker-compose.dev.yml up -d --build
|
||||
### Development Ports
|
||||
The development setup exposes additional ports for debugging:
|
||||
- **Database**: `5432` - Direct PostgreSQL access
|
||||
- **Redis**: `6379` - Direct Redis access
|
||||
- **Backend**: `3001` - API server with development features
|
||||
- **Frontend**: `3000` - React development server with hot reload
|
||||
|
||||
@@ -277,8 +327,8 @@ The development setup exposes additional ports for debugging:
|
||||
- **Prisma Schema Changes**: Backend service restarts automatically
|
||||
|
||||
4. **Database Access**: Connect database client directly to `localhost:5432`
|
||||
|
||||
5. **Debug**: If started with `docker compose [...] up -d` or `docker compose [...] watch`, check logs manually:
|
||||
5. **Redis Access**: Connect Redis client directly to `localhost:6379`
|
||||
6. **Debug**: If started with `docker compose [...] up -d` or `docker compose [...] watch`, check logs manually:
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.dev.yml logs -f
|
||||
```
|
||||
@@ -288,6 +338,6 @@ The development setup exposes additional ports for debugging:
|
||||
|
||||
- **Hot Reload**: Automatic code synchronization and service restarts
|
||||
- **Enhanced Logging**: Detailed logs for debugging
|
||||
- **Direct Access**: Exposed ports for database and API debugging
|
||||
- **Direct Access**: Exposed ports for database, Redis, and API debugging
|
||||
- **Health Checks**: Built-in health monitoring for services
|
||||
- **Volume Persistence**: Development data persists between restarts
|
||||
|
||||
@@ -8,7 +8,7 @@ ENV NODE_ENV=development \
|
||||
PM_LOG_TO_CONSOLE=true \
|
||||
PORT=3001
|
||||
|
||||
RUN apk add --no-cache openssl tini curl
|
||||
RUN apk add --no-cache openssl tini curl libc6-compat
|
||||
|
||||
USER node
|
||||
|
||||
@@ -46,8 +46,10 @@ COPY --chown=node:node backend/ ./backend/
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
RUN npm ci --ignore-scripts &&\
|
||||
npx prisma generate &&\
|
||||
RUN npm cache clean --force &&\
|
||||
rm -rf node_modules ~/.npm /root/.npm &&\
|
||||
npm ci --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=0 &&\
|
||||
PRISMA_CLI_BINARY_TYPE=binary npm run db:generate &&\
|
||||
npm prune --omit=dev &&\
|
||||
npm cache clean --force
|
||||
|
||||
@@ -64,7 +66,7 @@ ENV NODE_ENV=production \
|
||||
JWT_REFRESH_EXPIRES_IN=7d \
|
||||
SESSION_INACTIVITY_TIMEOUT_MINUTES=30
|
||||
|
||||
RUN apk add --no-cache openssl tini curl
|
||||
RUN apk add --no-cache openssl tini curl libc6-compat
|
||||
|
||||
USER node
|
||||
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
**/env.example
|
||||
**/.env
|
||||
**/.env.*
|
||||
|
||||
@@ -8,7 +8,7 @@ log() {
|
||||
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" >&2
|
||||
}
|
||||
|
||||
# Function to extract version from agent script
|
||||
# Function to extract version from agent script (legacy)
|
||||
get_agent_version() {
|
||||
local file="$1"
|
||||
if [ -f "$file" ]; then
|
||||
@@ -18,6 +18,32 @@ get_agent_version() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to get version from binary using --help flag
|
||||
get_binary_version() {
|
||||
local binary="$1"
|
||||
if [ -f "$binary" ]; then
|
||||
# Make sure binary is executable
|
||||
chmod +x "$binary" 2>/dev/null || true
|
||||
|
||||
# Try to execute the binary and extract version from help output
|
||||
# The Go binary shows version in the --help output as "PatchMon Agent v1.3.0"
|
||||
local version=$("$binary" --help 2>&1 | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -n 1 | tr -d 'v')
|
||||
if [ -n "$version" ]; then
|
||||
echo "$version"
|
||||
else
|
||||
# Fallback: try --version flag
|
||||
version=$("$binary" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n 1)
|
||||
if [ -n "$version" ]; then
|
||||
echo "$version"
|
||||
else
|
||||
echo "0.0.0"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "0.0.0"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to compare versions (returns 0 if $1 > $2)
|
||||
version_greater() {
|
||||
# Use sort -V for version comparison
|
||||
@@ -28,6 +54,8 @@ version_greater() {
|
||||
update_agents() {
|
||||
local backup_agent="/app/agents_backup/patchmon-agent.sh"
|
||||
local current_agent="/app/agents/patchmon-agent.sh"
|
||||
local backup_binary="/app/agents_backup/patchmon-agent-linux-amd64"
|
||||
local current_binary="/app/agents/patchmon-agent-linux-amd64"
|
||||
|
||||
# Check if agents directory exists
|
||||
if [ ! -d "/app/agents" ]; then
|
||||
@@ -41,51 +69,72 @@ update_agents() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Get versions
|
||||
local backup_version=$(get_agent_version "$backup_agent")
|
||||
local current_version=$(get_agent_version "$current_agent")
|
||||
# Get versions from both script and binary
|
||||
local backup_script_version=$(get_agent_version "$backup_agent")
|
||||
local current_script_version=$(get_agent_version "$current_agent")
|
||||
local backup_binary_version=$(get_binary_version "$backup_binary")
|
||||
local current_binary_version=$(get_binary_version "$current_binary")
|
||||
|
||||
log "Agent version check:"
|
||||
log " Image version: ${backup_version}"
|
||||
log " Volume version: ${current_version}"
|
||||
log " Image script version: ${backup_script_version}"
|
||||
log " Volume script version: ${current_script_version}"
|
||||
log " Image binary version: ${backup_binary_version}"
|
||||
log " Volume binary version: ${current_binary_version}"
|
||||
|
||||
# Determine if update is needed
|
||||
local needs_update=0
|
||||
|
||||
# Case 1: No agents in volume (first time setup)
|
||||
if [ -z "$(find /app/agents -maxdepth 1 -type f -name '*.sh' 2>/dev/null | head -n 1)" ]; then
|
||||
# Case 1: No agents in volume at all (first time setup)
|
||||
if [ -z "$(find /app/agents -maxdepth 1 -type f 2>/dev/null | head -n 1)" ]; then
|
||||
log "Agents directory is empty - performing initial copy"
|
||||
needs_update=1
|
||||
# Case 2: Backup version is newer
|
||||
elif version_greater "$backup_version" "$current_version"; then
|
||||
log "Newer agent version available (${backup_version} > ${current_version})"
|
||||
# Case 2: Binary exists but backup binary is newer
|
||||
elif [ "$current_binary_version" != "0.0.0" ] && version_greater "$backup_binary_version" "$current_binary_version"; then
|
||||
log "Newer agent binary available (${backup_binary_version} > ${current_binary_version})"
|
||||
needs_update=1
|
||||
# Case 3: No binary in volume, but shell scripts exist (legacy setup) - copy binaries
|
||||
elif [ "$current_binary_version" = "0.0.0" ] && [ "$backup_binary_version" != "0.0.0" ]; then
|
||||
log "No binary found in volume but backup has binaries - performing update"
|
||||
needs_update=1
|
||||
else
|
||||
log "Agents are up to date"
|
||||
log "Agents are up to date (binary: ${current_binary_version})"
|
||||
needs_update=0
|
||||
fi
|
||||
|
||||
# Perform update if needed
|
||||
if [ $needs_update -eq 1 ]; then
|
||||
log "Updating agents to version ${backup_version}..."
|
||||
log "Updating agents to version ${backup_binary_version}..."
|
||||
|
||||
# Create backup of existing agents if they exist
|
||||
if [ -f "$current_agent" ]; then
|
||||
if [ -f "$current_agent" ] || [ -f "$current_binary" ]; then
|
||||
local backup_timestamp=$(date +%Y%m%d_%H%M%S)
|
||||
local backup_name="/app/agents/patchmon-agent.sh.backup.${backup_timestamp}"
|
||||
cp "$current_agent" "$backup_name" 2>/dev/null || true
|
||||
log "Previous agent backed up to: $(basename $backup_name)"
|
||||
mkdir -p "/app/agents/backups"
|
||||
|
||||
# Backup shell script if it exists
|
||||
if [ -f "$current_agent" ]; then
|
||||
cp "$current_agent" "/app/agents/backups/patchmon-agent.sh.${backup_timestamp}" 2>/dev/null || true
|
||||
log "Previous script backed up"
|
||||
fi
|
||||
|
||||
# Backup binary if it exists
|
||||
if [ -f "$current_binary" ]; then
|
||||
cp "$current_binary" "/app/agents/backups/patchmon-agent-linux-amd64.${backup_timestamp}" 2>/dev/null || true
|
||||
log "Previous binary backed up"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Copy new agents
|
||||
# Copy new agents (both scripts and binaries)
|
||||
cp -r /app/agents_backup/* /app/agents/
|
||||
|
||||
# Make agent binaries executable
|
||||
chmod +x /app/agents/patchmon-agent-linux-* 2>/dev/null || true
|
||||
|
||||
# Verify update
|
||||
local new_version=$(get_agent_version "$current_agent")
|
||||
if [ "$new_version" = "$backup_version" ]; then
|
||||
log "✅ Agents successfully updated to version ${new_version}"
|
||||
local new_binary_version=$(get_binary_version "$current_binary")
|
||||
if [ "$new_binary_version" = "$backup_binary_version" ]; then
|
||||
log "✅ Agents successfully updated to version ${new_binary_version}"
|
||||
else
|
||||
log "⚠️ Warning: Agent update may have failed (expected: ${backup_version}, got: ${new_version})"
|
||||
log "⚠️ Warning: Agent update may have failed (expected: ${backup_binary_version}, got: ${new_binary_version})"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -18,6 +18,22 @@ services:
|
||||
timeout: 5s
|
||||
retries: 7
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: redis-server --requirepass 1NS3CU6E_DEV_R3DIS_PASSW0RD
|
||||
environment:
|
||||
REDIS_PASSWORD: 1NS3CU6E_DEV_R3DIS_PASSW0RD
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- ./compose_dev_data/redis:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "1NS3CU6E_DEV_R3DIS_PASSW0RD", "ping"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 7
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ..
|
||||
@@ -34,6 +50,24 @@ services:
|
||||
SERVER_HOST: localhost
|
||||
SERVER_PORT: 3000
|
||||
CORS_ORIGIN: http://localhost:3000
|
||||
# Database Connection Pool Configuration (Prisma)
|
||||
DB_CONNECTION_LIMIT: 30
|
||||
DB_POOL_TIMEOUT: 20
|
||||
DB_CONNECT_TIMEOUT: 10
|
||||
DB_IDLE_TIMEOUT: 300
|
||||
DB_MAX_LIFETIME: 1800
|
||||
# Rate Limiting (times in milliseconds)
|
||||
RATE_LIMIT_WINDOW_MS: 900000
|
||||
RATE_LIMIT_MAX: 5000
|
||||
AUTH_RATE_LIMIT_WINDOW_MS: 600000
|
||||
AUTH_RATE_LIMIT_MAX: 500
|
||||
AGENT_RATE_LIMIT_WINDOW_MS: 60000
|
||||
AGENT_RATE_LIMIT_MAX: 1000
|
||||
# Redis Configuration
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
REDIS_PASSWORD: 1NS3CU6E_DEV_R3DIS_PASSW0RD
|
||||
REDIS_DB: 0
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
@@ -41,6 +75,8 @@ services:
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
develop:
|
||||
watch:
|
||||
- action: sync
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
# Change 3 Passwords in this file:
|
||||
# Generate passwords with 'openssl rand -hex 64'
|
||||
#
|
||||
# 1. The database password in the environment variable POSTGRES_PASSWORD
|
||||
# 2. The redis password in the command redis-server --requirepass your-redis-password-here
|
||||
# 3. The jwt secret in the environment variable JWT_SECRET
|
||||
#
|
||||
#
|
||||
# Change 2 URL areas in this file:
|
||||
# 1. Setup your CORS_ORIGIN to what url you will use for accessing PatchMon frontend url
|
||||
# 2. Setup your SERVER_PROTOCOL, SERVER_HOST and SERVER_PORT to what you will use for linux agents to access PatchMon
|
||||
#
|
||||
# This is generally the same as your CORS_ORIGIN url , in some cases it might be different - SERVER_* variables are used in the scripts for Server connection.
|
||||
# You can also change this in the front-end but in the case of docker-compose - it is overwritten by the variables set here.
|
||||
|
||||
|
||||
name: patchmon
|
||||
|
||||
services:
|
||||
@@ -7,7 +23,7 @@ services:
|
||||
environment:
|
||||
POSTGRES_DB: patchmon_db
|
||||
POSTGRES_USER: patchmon_user
|
||||
POSTGRES_PASSWORD: # CREATE A STRONG PASSWORD AND PUT IT HERE
|
||||
POSTGRES_PASSWORD: # CREATE A STRONG DB PASSWORD AND PUT IT HERE
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -16,6 +32,18 @@ services:
|
||||
timeout: 5s
|
||||
retries: 7
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: redis-server --requirepass your-redis-password-here # CHANGE THIS TO YOUR REDIS PASSWORD
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "your-redis-password-here", "ping"] # CHANGE THIS TO YOUR REDIS PASSWORD
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 7
|
||||
|
||||
backend:
|
||||
image: ghcr.io/patchmon/patchmon-backend:latest
|
||||
restart: unless-stopped
|
||||
@@ -23,16 +51,36 @@ services:
|
||||
environment:
|
||||
LOG_LEVEL: info
|
||||
DATABASE_URL: postgresql://patchmon_user:REPLACE_YOUR_POSTGRES_PASSWORD_HERE@database:5432/patchmon_db
|
||||
JWT_SECRET: # CREATE A STRONG SECRET AND PUT IT HERE - Generate with 'openssl rand -hex 64'
|
||||
JWT_SECRET: # CREATE A STRONG SECRET AND PUT IT HERE
|
||||
SERVER_PROTOCOL: http
|
||||
SERVER_HOST: localhost
|
||||
SERVER_PORT: 3000
|
||||
CORS_ORIGIN: http://localhost:3000
|
||||
# Database Connection Pool Configuration (Prisma)
|
||||
DB_CONNECTION_LIMIT: 30
|
||||
DB_POOL_TIMEOUT: 20
|
||||
DB_CONNECT_TIMEOUT: 10
|
||||
DB_IDLE_TIMEOUT: 300
|
||||
DB_MAX_LIFETIME: 1800
|
||||
# Rate Limiting (times in milliseconds)
|
||||
RATE_LIMIT_WINDOW_MS: 900000
|
||||
RATE_LIMIT_MAX: 5000
|
||||
AUTH_RATE_LIMIT_WINDOW_MS: 600000
|
||||
AUTH_RATE_LIMIT_MAX: 500
|
||||
AGENT_RATE_LIMIT_WINDOW_MS: 60000
|
||||
AGENT_RATE_LIMIT_MAX: 1000
|
||||
# Redis Configuration
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
REDIS_PASSWORD: your-redis-password-here
|
||||
REDIS_DB: 0
|
||||
volumes:
|
||||
- agent_files:/app/agents
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
image: ghcr.io/patchmon/patchmon-frontend:latest
|
||||
@@ -45,4 +93,5 @@ services:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
agent_files:
|
||||
|
||||
@@ -17,16 +17,17 @@ CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
|
||||
# Builder stage for production
|
||||
FROM node:lts-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY package*.json ./
|
||||
COPY frontend/package*.json ./frontend/
|
||||
COPY frontend/package*.json ./
|
||||
|
||||
RUN npm ci --ignore-scripts
|
||||
RUN npm cache clean --force &&\
|
||||
rm -rf node_modules ~/.npm /root/.npm &&\
|
||||
npm install --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=0
|
||||
|
||||
COPY frontend/ ./frontend/
|
||||
COPY frontend/ ./
|
||||
|
||||
RUN npm run build:frontend
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginxinc/nginx-unprivileged:alpine
|
||||
|
||||
@@ -24,6 +24,38 @@ server {
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Bull Board proxy - must come before the root location to avoid conflicts
|
||||
location /bullboard {
|
||||
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Cookie $http_cookie; # Forward cookies to backend
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
|
||||
# Enable cookie passthrough in both directions
|
||||
proxy_pass_header Set-Cookie;
|
||||
proxy_cookie_path / /;
|
||||
|
||||
# Preserve original client IP through proxy chain
|
||||
proxy_set_header X-Original-Forwarded-For $http_x_forwarded_for;
|
||||
|
||||
# CORS headers for Bull Board - let backend handle CORS properly
|
||||
# Note: Backend handles CORS with proper origin validation and credentials
|
||||
|
||||
# Handle preflight requests
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# Handle client-side routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
@@ -38,13 +70,19 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
# For the Websocket connection:
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
|
||||
# Preserve original client IP through proxy chain
|
||||
proxy_set_header X-Original-Forwarded-For $http_x_forwarded_for;
|
||||
|
||||
# CORS headers for API calls
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization" always;
|
||||
# CORS headers for API calls - let backend handle CORS properly
|
||||
# Note: Backend handles CORS with proper origin validation and credentials
|
||||
|
||||
# Handle preflight requests
|
||||
if ($request_method = 'OPTIONS') {
|
||||
@@ -52,8 +90,9 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# Static assets caching
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
|
||||
# Static assets caching (exclude Bull Board assets)
|
||||
location ~* ^/(?!bullboard).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
10
frontend/env.example
Normal file
10
frontend/env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# Frontend Environment Configuration
|
||||
# This file is used by Vite during build and runtime
|
||||
|
||||
# API URL - Update this to match your backend server
|
||||
VITE_API_URL=http://localhost:3001/api/v1
|
||||
|
||||
# Application Metadata
|
||||
VITE_APP_NAME=PatchMon
|
||||
VITE_APP_VERSION=1.3.1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "patchmon-frontend",
|
||||
"private": true,
|
||||
"version": "1.2.9",
|
||||
"version": "1.3.1",
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -27,12 +27,13 @@
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^6.30.1"
|
||||
"react-router-dom": "^6.30.1",
|
||||
"trianglify": "^4.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.14",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
|
||||
23
frontend/public/assets/bull-board-logo.svg
Normal file
23
frontend/public/assets/bull-board-logo.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36">
|
||||
<circle fill="#DD2E44" cx="18" cy="18" r="18" />
|
||||
<circle fill="#FFF" cx="18" cy="18" r="13.5" />
|
||||
<circle fill="#DD2E44" cx="18" cy="18" r="10" />
|
||||
<circle fill="#FFF" cx="18" cy="18" r="6" />
|
||||
<circle fill="#DD2E44" cx="18" cy="18" r="3" />
|
||||
<path
|
||||
opacity=".2"
|
||||
d="M18.24 18.282l13.144 11.754s-2.647 3.376-7.89 5.109L17.579 18.42l.661-.138z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFAC33"
|
||||
d="M18.294 19a.994.994 0 01-.704-1.699l.563-.563a.995.995 0 011.408 1.407l-.564.563a.987.987 0 01-.703.292z"
|
||||
/>
|
||||
<path
|
||||
fill="#55ACEE"
|
||||
d="M24.016 6.981c-.403 2.079 0 4.691 0 4.691l7.054-7.388c.291-1.454-.528-3.932-1.718-4.238-1.19-.306-4.079.803-5.336 6.935zm5.003 5.003c-2.079.403-4.691 0-4.691 0l7.388-7.054c1.454-.291 3.932.528 4.238 1.718.306 1.19-.803 4.079-6.935 5.336z"
|
||||
/>
|
||||
<path
|
||||
fill="#3A87C2"
|
||||
d="M32.798 4.485L21.176 17.587c-.362.362-1.673.882-2.51.046-.836-.836-.419-2.08-.057-2.443L31.815 3.501s.676-.635 1.159-.152-.176 1.136-.176 1.136z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -7,6 +7,7 @@ import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import SettingsLayout from "./components/SettingsLayout";
|
||||
import { isAuthPhase } from "./constants/authPhases";
|
||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
||||
import { ColorThemeProvider } from "./contexts/ColorThemeContext";
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
|
||||
|
||||
@@ -41,6 +42,7 @@ const SettingsServerConfig = lazy(
|
||||
() => import("./pages/settings/SettingsServerConfig"),
|
||||
);
|
||||
const SettingsUsers = lazy(() => import("./pages/settings/SettingsUsers"));
|
||||
const SettingsMetrics = lazy(() => import("./pages/settings/SettingsMetrics"));
|
||||
|
||||
// Loading fallback component
|
||||
const LoadingFallback = () => (
|
||||
@@ -388,6 +390,16 @@ function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/metrics"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsMetrics />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/options"
|
||||
element={
|
||||
@@ -416,13 +428,15 @@ function AppRoutes() {
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<UpdateNotificationProvider>
|
||||
<LogoProvider>
|
||||
<AppRoutes />
|
||||
</LogoProvider>
|
||||
</UpdateNotificationProvider>
|
||||
</AuthProvider>
|
||||
<ColorThemeProvider>
|
||||
<AuthProvider>
|
||||
<UpdateNotificationProvider>
|
||||
<LogoProvider>
|
||||
<AppRoutes />
|
||||
</LogoProvider>
|
||||
</UpdateNotificationProvider>
|
||||
</AuthProvider>
|
||||
</ColorThemeProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AlertCircle, CheckCircle, Shield, UserPlus } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { isCorsError } from "../utils/api";
|
||||
|
||||
const FirstTimeAdminSetup = () => {
|
||||
const { login, setAuthState } = useAuth();
|
||||
@@ -121,11 +122,39 @@ const FirstTimeAdminSetup = () => {
|
||||
}, 2000);
|
||||
}
|
||||
} else {
|
||||
setError(data.error || "Failed to create admin user");
|
||||
// Handle HTTP error responses (like 500 CORS errors)
|
||||
console.log("HTTP error response:", response.status, data);
|
||||
|
||||
// Check if this is a CORS error based on the response data
|
||||
if (
|
||||
data.message?.includes("Not allowed by CORS") ||
|
||||
data.message?.includes("CORS") ||
|
||||
data.error?.includes("CORS")
|
||||
) {
|
||||
setError(
|
||||
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
|
||||
);
|
||||
} else {
|
||||
setError(data.error || "Failed to create admin user");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Setup error:", error);
|
||||
setError("Network error. Please try again.");
|
||||
// Check for CORS/network errors first
|
||||
if (isCorsError(error)) {
|
||||
setError(
|
||||
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
|
||||
);
|
||||
} else if (
|
||||
error.name === "TypeError" &&
|
||||
error.message?.includes("Failed to fetch")
|
||||
) {
|
||||
setError(
|
||||
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
|
||||
);
|
||||
} else {
|
||||
setError("Network error. Please try again.");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
283
frontend/src/components/InlineMultiGroupEdit.jsx
Normal file
283
frontend/src/components/InlineMultiGroupEdit.jsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { Check, ChevronDown, Edit2, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
const InlineMultiGroupEdit = ({
|
||||
value = [], // Array of group IDs
|
||||
onSave,
|
||||
onCancel,
|
||||
options = [],
|
||||
className = "",
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [selectedValues, setSelectedValues] = useState(value);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [dropdownPosition, setDropdownPosition] = useState({
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
});
|
||||
const dropdownRef = useRef(null);
|
||||
const buttonRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && dropdownRef.current) {
|
||||
dropdownRef.current.focus();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedValues(value);
|
||||
// Force re-render when value changes
|
||||
if (!isEditing) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [value, isEditing]);
|
||||
|
||||
// Calculate dropdown position
|
||||
const calculateDropdownPosition = useCallback(() => {
|
||||
if (buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + window.scrollY + 4,
|
||||
left: rect.left + window.scrollX,
|
||||
width: rect.width,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
calculateDropdownPosition();
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
window.addEventListener("resize", calculateDropdownPosition);
|
||||
window.addEventListener("scroll", calculateDropdownPosition);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
window.removeEventListener("resize", calculateDropdownPosition);
|
||||
window.removeEventListener("scroll", calculateDropdownPosition);
|
||||
};
|
||||
}
|
||||
}, [isOpen, calculateDropdownPosition]);
|
||||
|
||||
const handleEdit = () => {
|
||||
if (disabled) return;
|
||||
setIsEditing(true);
|
||||
setSelectedValues(value);
|
||||
setError("");
|
||||
// Automatically open dropdown when editing starts
|
||||
setTimeout(() => {
|
||||
setIsOpen(true);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setSelectedValues(value);
|
||||
setError("");
|
||||
setIsOpen(false);
|
||||
if (onCancel) onCancel();
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (disabled || isLoading) return;
|
||||
|
||||
// Check if values actually changed
|
||||
const sortedCurrent = [...value].sort();
|
||||
const sortedSelected = [...selectedValues].sort();
|
||||
if (JSON.stringify(sortedCurrent) === JSON.stringify(sortedSelected)) {
|
||||
setIsEditing(false);
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
await onSave(selectedValues);
|
||||
setIsEditing(false);
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
setError(err.message || "Failed to save");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGroup = (groupId) => {
|
||||
setSelectedValues((prev) => {
|
||||
if (prev.includes(groupId)) {
|
||||
return prev.filter((id) => id !== groupId);
|
||||
} else {
|
||||
return [...prev, groupId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const _displayValue = useMemo(() => {
|
||||
if (!value || value.length === 0) {
|
||||
return "Ungrouped";
|
||||
}
|
||||
if (value.length === 1) {
|
||||
const option = options.find((opt) => opt.id === value[0]);
|
||||
return option ? option.name : "Unknown Group";
|
||||
}
|
||||
return `${value.length} groups`;
|
||||
}, [value, options]);
|
||||
|
||||
const displayGroups = useMemo(() => {
|
||||
if (!value || value.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.map((groupId) => options.find((opt) => opt.id === groupId))
|
||||
.filter(Boolean);
|
||||
}, [value, options]);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className={`relative ${className}`} ref={dropdownRef}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isLoading}
|
||||
className={`w-full px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent flex items-center justify-between ${
|
||||
error ? "border-red-500" : ""
|
||||
} ${isLoading ? "opacity-50" : ""}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedValues.length === 0
|
||||
? "Ungrouped"
|
||||
: selectedValues.length === 1
|
||||
? options.find((opt) => opt.id === selectedValues[0])
|
||||
?.name || "Unknown Group"
|
||||
: `${selectedValues.length} groups selected`}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed z-50 bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-lg max-h-60 overflow-auto"
|
||||
style={{
|
||||
top: `${dropdownPosition.top}px`,
|
||||
left: `${dropdownPosition.left}px`,
|
||||
width: `${dropdownPosition.width}px`,
|
||||
minWidth: "200px",
|
||||
}}
|
||||
>
|
||||
<div className="py-1">
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.id}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 flex items-center cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedValues.includes(option.id)}
|
||||
onChange={() => toggleGroup(option.id)}
|
||||
className="mr-2 h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
|
||||
/>
|
||||
<span
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
|
||||
style={{ backgroundColor: option.color }}
|
||||
>
|
||||
{option.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{options.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
No groups available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading}
|
||||
className="p-1 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Save"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<span className="text-xs text-red-600 dark:text-red-400 mt-1 block">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1 group ${className}`}>
|
||||
{displayGroups.length === 0 ? (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800">
|
||||
Ungrouped
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{displayGroups.map((group) => (
|
||||
<span
|
||||
key={group.id}
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
|
||||
style={{ backgroundColor: group.color }}
|
||||
>
|
||||
{group.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Edit groups"
|
||||
>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InlineMultiGroupEdit;
|
||||
@@ -26,9 +26,11 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FaYoutube } from "react-icons/fa";
|
||||
import { FaReddit, FaYoutube } from "react-icons/fa";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import trianglify from "trianglify";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useColorTheme } from "../contexts/ColorThemeContext";
|
||||
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
|
||||
import { dashboardAPI, versionAPI } from "../utils/api";
|
||||
import DiscordIcon from "./DiscordIcon";
|
||||
@@ -61,7 +63,9 @@ const Layout = ({ children }) => {
|
||||
canManageSettings,
|
||||
} = useAuth();
|
||||
const { updateAvailable } = useUpdateNotification();
|
||||
const { themeConfig } = useColorTheme();
|
||||
const userMenuRef = useRef(null);
|
||||
const bgCanvasRef = useRef(null);
|
||||
|
||||
// Fetch dashboard stats for the "Last updated" info
|
||||
const {
|
||||
@@ -117,7 +121,7 @@ const Layout = ({ children }) => {
|
||||
name: "Automation",
|
||||
href: "/automation",
|
||||
icon: RefreshCw,
|
||||
beta: true,
|
||||
new: true,
|
||||
});
|
||||
|
||||
if (canViewReports()) {
|
||||
@@ -233,27 +237,103 @@ const Layout = ({ children }) => {
|
||||
navigate("/hosts?action=add");
|
||||
};
|
||||
|
||||
// Generate Trianglify background for dark mode
|
||||
useEffect(() => {
|
||||
const generateBackground = () => {
|
||||
if (
|
||||
bgCanvasRef.current &&
|
||||
themeConfig?.login &&
|
||||
document.documentElement.classList.contains("dark")
|
||||
) {
|
||||
// Get current date as seed for daily variation
|
||||
const today = new Date();
|
||||
const dateSeed = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
|
||||
|
||||
// Generate pattern with selected theme configuration
|
||||
const pattern = trianglify({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
cellSize: themeConfig.login.cellSize,
|
||||
variance: themeConfig.login.variance,
|
||||
seed: dateSeed,
|
||||
xColors: themeConfig.login.xColors,
|
||||
yColors: themeConfig.login.yColors,
|
||||
});
|
||||
|
||||
// Render to canvas
|
||||
pattern.toCanvas(bgCanvasRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
generateBackground();
|
||||
|
||||
// Regenerate on window resize or theme change
|
||||
const handleResize = () => {
|
||||
generateBackground();
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
// Watch for dark mode changes
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === "class") {
|
||||
generateBackground();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [themeConfig]);
|
||||
|
||||
// Fetch GitHub stars count
|
||||
const fetchGitHubStars = useCallback(async () => {
|
||||
// Skip if already fetched recently
|
||||
// Try to load cached star count first
|
||||
const cachedStars = localStorage.getItem("githubStarsCount");
|
||||
if (cachedStars) {
|
||||
setGithubStars(parseInt(cachedStars, 10));
|
||||
}
|
||||
|
||||
// Skip API call if fetched recently
|
||||
const lastFetch = localStorage.getItem("githubStarsFetchTime");
|
||||
const now = Date.now();
|
||||
if (lastFetch && now - parseInt(lastFetch, 15) < 600000) {
|
||||
// 15 minute cache
|
||||
if (lastFetch && now - parseInt(lastFetch, 10) < 600000) {
|
||||
// 10 minute cache
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://api.github.com/repos/9technologygroup/patchmon.net",
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setGithubStars(data.stargazers_count);
|
||||
localStorage.setItem(
|
||||
"githubStarsCount",
|
||||
data.stargazers_count.toString(),
|
||||
);
|
||||
localStorage.setItem("githubStarsFetchTime", now.toString());
|
||||
} else if (response.status === 403 || response.status === 429) {
|
||||
console.warn("GitHub API rate limit exceeded, using cached value");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch GitHub stars:", error);
|
||||
// Keep using cached value if available
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -303,11 +383,76 @@ const Layout = ({ children }) => {
|
||||
fetchGitHubStars();
|
||||
}, [fetchGitHubStars]);
|
||||
|
||||
// Set CSS custom properties for glassmorphism and theme colors in dark mode
|
||||
useEffect(() => {
|
||||
const updateThemeStyles = () => {
|
||||
const isDark = document.documentElement.classList.contains("dark");
|
||||
const root = document.documentElement;
|
||||
|
||||
if (isDark && themeConfig?.app) {
|
||||
// Glass navigation bars - very light for pattern visibility
|
||||
root.style.setProperty("--sidebar-bg", "rgba(0, 0, 0, 0.15)");
|
||||
root.style.setProperty("--sidebar-blur", "blur(12px)");
|
||||
root.style.setProperty("--topbar-bg", "rgba(0, 0, 0, 0.15)");
|
||||
root.style.setProperty("--topbar-blur", "blur(12px)");
|
||||
root.style.setProperty("--button-bg", "rgba(255, 255, 255, 0.15)");
|
||||
root.style.setProperty("--button-blur", "blur(8px)");
|
||||
|
||||
// Theme-colored cards and buttons - darker to stand out
|
||||
root.style.setProperty("--card-bg", themeConfig.app.cardBg);
|
||||
root.style.setProperty("--card-border", themeConfig.app.cardBorder);
|
||||
root.style.setProperty("--card-bg-hover", themeConfig.app.bgTertiary);
|
||||
root.style.setProperty("--theme-button-bg", themeConfig.app.buttonBg);
|
||||
root.style.setProperty(
|
||||
"--theme-button-hover",
|
||||
themeConfig.app.buttonHover,
|
||||
);
|
||||
} else {
|
||||
// Light mode - standard colors
|
||||
root.style.setProperty("--sidebar-bg", "white");
|
||||
root.style.setProperty("--sidebar-blur", "none");
|
||||
root.style.setProperty("--topbar-bg", "white");
|
||||
root.style.setProperty("--topbar-blur", "none");
|
||||
root.style.setProperty("--button-bg", "white");
|
||||
root.style.setProperty("--button-blur", "none");
|
||||
root.style.setProperty("--card-bg", "white");
|
||||
root.style.setProperty("--card-border", "#e5e7eb");
|
||||
root.style.setProperty("--card-bg-hover", "#f9fafb");
|
||||
root.style.setProperty("--theme-button-bg", "#f3f4f6");
|
||||
root.style.setProperty("--theme-button-hover", "#e5e7eb");
|
||||
}
|
||||
};
|
||||
|
||||
updateThemeStyles();
|
||||
|
||||
// Watch for dark mode changes
|
||||
const observer = new MutationObserver(() => {
|
||||
updateThemeStyles();
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [themeConfig]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-secondary-50">
|
||||
<div className="min-h-screen bg-secondary-50 dark:bg-black relative overflow-hidden">
|
||||
{/* Full-screen Trianglify Background (Dark Mode Only) */}
|
||||
<canvas
|
||||
ref={bgCanvasRef}
|
||||
className="fixed inset-0 w-full h-full hidden dark:block"
|
||||
style={{ zIndex: 0 }}
|
||||
/>
|
||||
<div
|
||||
className="fixed inset-0 bg-gradient-to-br from-black/10 to-black/20 hidden dark:block pointer-events-none"
|
||||
style={{ zIndex: 1 }}
|
||||
/>
|
||||
{/* Mobile sidebar */}
|
||||
<div
|
||||
className={`fixed inset-0 z-50 lg:hidden ${sidebarOpen ? "block" : "hidden"}`}
|
||||
className={`fixed inset-0 z-[60] lg:hidden ${sidebarOpen ? "block" : "hidden"}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -315,7 +460,14 @@ const Layout = ({ children }) => {
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-label="Close sidebar"
|
||||
/>
|
||||
<div className="relative flex w-full max-w-[280px] flex-col bg-white dark:bg-secondary-800 pb-4 pt-5 shadow-xl">
|
||||
<div
|
||||
className="relative flex w-full max-w-[280px] flex-col bg-white dark:border-r dark:border-white/10 pb-4 pt-5 shadow-xl"
|
||||
style={{
|
||||
backgroundColor: "var(--sidebar-bg, white)",
|
||||
backdropFilter: "var(--sidebar-blur, none)",
|
||||
WebkitBackdropFilter: "var(--sidebar-blur, none)",
|
||||
}}
|
||||
>
|
||||
<div className="absolute right-0 top-0 -mr-12 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -440,6 +592,11 @@ const Layout = ({ children }) => {
|
||||
Beta
|
||||
</span>
|
||||
)}
|
||||
{subItem.new && (
|
||||
<span className="text-xs bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-200 px-1.5 py-0.5 rounded font-medium">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
@@ -529,17 +686,43 @@ const Layout = ({ children }) => {
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div
|
||||
className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 relative ${
|
||||
className={`hidden lg:fixed lg:inset-y-0 z-[100] lg:flex lg:flex-col transition-all duration-300 relative ${
|
||||
sidebarCollapsed ? "lg:w-16" : "lg:w-56"
|
||||
} bg-white dark:bg-secondary-800`}
|
||||
} bg-white dark:bg-transparent`}
|
||||
>
|
||||
{/* Collapse/Expand button on border */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="absolute top-5 -right-3 z-[200] flex items-center justify-center w-6 h-6 rounded-full bg-white border border-secondary-300 dark:border-white/20 shadow-md hover:bg-secondary-50 transition-colors"
|
||||
style={{
|
||||
backgroundColor: "var(--button-bg, white)",
|
||||
backdropFilter: "var(--button-blur, none)",
|
||||
WebkitBackdropFilter: "var(--button-blur, none)",
|
||||
}}
|
||||
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4 text-secondary-700 dark:text-white" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4 text-secondary-700 dark:text-white" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`flex grow flex-col gap-y-5 overflow-y-auto border-r border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 ${
|
||||
className={`flex grow flex-col gap-y-5 border-r border-secondary-200 dark:border-white/10 bg-white ${
|
||||
sidebarCollapsed ? "px-2 shadow-lg" : "px-6"
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: "var(--sidebar-bg, white)",
|
||||
backdropFilter: "var(--sidebar-blur, none)",
|
||||
WebkitBackdropFilter: "var(--sidebar-blur, none)",
|
||||
overflowY: "auto",
|
||||
overflowX: "visible",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`flex h-16 shrink-0 items-center border-b border-secondary-200 dark:border-secondary-600 ${
|
||||
className={`flex h-16 shrink-0 items-center border-b border-secondary-200 dark:border-white/10 ${
|
||||
sidebarCollapsed ? "justify-center" : "justify-center"
|
||||
}`}
|
||||
>
|
||||
@@ -557,19 +740,6 @@ const Layout = ({ children }) => {
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{/* Collapse/Expand button on border */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="absolute top-5 -right-3 z-10 flex items-center justify-center w-6 h-6 rounded-full bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 shadow-md hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
||||
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4 text-secondary-700 dark:text-white" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4 text-secondary-700 dark:text-white" />
|
||||
)}
|
||||
</button>
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<ul className="flex flex-1 flex-col gap-y-6">
|
||||
{/* Show message for users with very limited permissions */}
|
||||
@@ -716,6 +886,11 @@ const Layout = ({ children }) => {
|
||||
Beta
|
||||
</span>
|
||||
)}
|
||||
{subItem.new && (
|
||||
<span className="text-xs bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-200 px-1.5 py-0.5 rounded font-medium">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
{subItem.showUpgradeIcon && (
|
||||
<UpgradeNotificationIcon className="h-3 w-3" />
|
||||
)}
|
||||
@@ -920,12 +1095,19 @@ const Layout = ({ children }) => {
|
||||
|
||||
{/* Main content */}
|
||||
<div
|
||||
className={`flex flex-col min-h-screen transition-all duration-300 ${
|
||||
className={`flex flex-col min-h-screen transition-all duration-300 relative z-10 ${
|
||||
sidebarCollapsed ? "lg:pl-16" : "lg:pl-56"
|
||||
}`}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8">
|
||||
<div
|
||||
className="sticky top-0 z-[90] flex h-16 shrink-0 items-center gap-x-4 border-b border-secondary-200 dark:border-white/10 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8"
|
||||
style={{
|
||||
backgroundColor: "var(--topbar-bg, white)",
|
||||
backdropFilter: "var(--topbar-blur, none)",
|
||||
WebkitBackdropFilter: "var(--topbar-blur, none)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="-m-2.5 p-2.5 text-secondary-700 dark:text-white lg:hidden"
|
||||
@@ -977,8 +1159,8 @@ const Layout = ({ children }) => {
|
||||
>
|
||||
<Github className="h-5 w-5 flex-shrink-0" />
|
||||
{githubStars !== null && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Star className="h-3 w-3 fill-current text-yellow-500" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4 fill-current text-yellow-500" />
|
||||
<span className="text-sm font-medium">{githubStars}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -1049,7 +1231,17 @@ const Layout = ({ children }) => {
|
||||
>
|
||||
<FaYoutube className="h-5 w-5" />
|
||||
</a>
|
||||
{/* 7) Web */}
|
||||
{/* 8) Reddit */}
|
||||
<a
|
||||
href="https://www.reddit.com/r/patchmon"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
||||
title="Reddit Community"
|
||||
>
|
||||
<FaReddit className="h-5 w-5" />
|
||||
</a>
|
||||
{/* 9) Web */}
|
||||
<a
|
||||
href="https://patchmon.net"
|
||||
target="_blank"
|
||||
@@ -1064,7 +1256,7 @@ const Layout = ({ children }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="flex-1 py-6 bg-secondary-50 dark:bg-secondary-800">
|
||||
<main className="flex-1 py-6 bg-secondary-50 dark:bg-transparent">
|
||||
<div className="px-4 sm:px-6 lg:px-8">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BarChart3,
|
||||
Bell,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -141,6 +142,11 @@ const SettingsLayout = ({ children }) => {
|
||||
href: "/settings/server-version",
|
||||
icon: Code,
|
||||
},
|
||||
{
|
||||
name: "Metrics",
|
||||
href: "/settings/metrics",
|
||||
icon: BarChart3,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,377 +1,453 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, Code, Download, Plus, Shield, X } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { agentFileAPI, settingsAPI } from "../../utils/api";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Download,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import api from "../../utils/api";
|
||||
|
||||
const AgentManagementTab = () => {
|
||||
const scriptFileId = useId();
|
||||
const scriptContentId = useId();
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const _queryClient = useQueryClient();
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
// Agent file queries and mutations
|
||||
const {
|
||||
data: agentFileInfo,
|
||||
isLoading: agentFileLoading,
|
||||
error: agentFileError,
|
||||
refetch: refetchAgentFile,
|
||||
} = useQuery({
|
||||
queryKey: ["agentFile"],
|
||||
queryFn: () => agentFileAPI.getInfo().then((res) => res.data),
|
||||
});
|
||||
// Auto-hide toast after 5 seconds
|
||||
useEffect(() => {
|
||||
if (toast) {
|
||||
const timer = setTimeout(() => {
|
||||
setToast(null);
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
// Fetch settings for dynamic curl flags
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||
});
|
||||
|
||||
// Helper function to get curl flags based on settings
|
||||
const getCurlFlags = () => {
|
||||
return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
|
||||
const showToast = (message, type = "success") => {
|
||||
setToast({ message, type });
|
||||
};
|
||||
|
||||
const uploadAgentMutation = useMutation({
|
||||
mutationFn: (scriptContent) =>
|
||||
agentFileAPI.upload(scriptContent).then((res) => res.data),
|
||||
// Agent version queries
|
||||
const {
|
||||
data: versionInfo,
|
||||
isLoading: versionLoading,
|
||||
error: versionError,
|
||||
refetch: refetchVersion,
|
||||
} = useQuery({
|
||||
queryKey: ["agentVersion"],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await api.get("/agent/version");
|
||||
console.log("🔍 Frontend received version info:", response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch version info:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes
|
||||
enabled: true, // Always enabled
|
||||
retry: 3, // Retry failed requests
|
||||
});
|
||||
|
||||
const {
|
||||
data: _availableVersions,
|
||||
isLoading: _versionsLoading,
|
||||
error: _versionsError,
|
||||
} = useQuery({
|
||||
queryKey: ["agentVersions"],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await api.get("/agent/versions");
|
||||
console.log("🔍 Frontend received available versions:", response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch available versions:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled: true,
|
||||
retry: 3,
|
||||
});
|
||||
|
||||
const checkUpdatesMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// First check GitHub for updates
|
||||
await api.post("/agent/version/check");
|
||||
// Then refresh current agent version detection
|
||||
await api.post("/agent/version/refresh");
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetchAgentFile();
|
||||
setShowUploadModal(false);
|
||||
refetchVersion();
|
||||
showToast("Successfully checked for updates", "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Upload agent error:", error);
|
||||
console.error("Check updates error:", error);
|
||||
showToast(`Failed to check for updates: ${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const downloadUpdateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Download the latest binaries
|
||||
const downloadResult = await api.post("/agent/version/download");
|
||||
// Refresh current agent version detection after download
|
||||
await api.post("/agent/version/refresh");
|
||||
// Return the download result for success handling
|
||||
return downloadResult;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
console.log("Download completed:", data);
|
||||
console.log("Download response data:", data.data);
|
||||
refetchVersion();
|
||||
// Show success message
|
||||
const message =
|
||||
data.data?.message || "Agent binaries downloaded successfully";
|
||||
showToast(message, "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Download update error:", error);
|
||||
showToast(`Download failed: ${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const getVersionStatus = () => {
|
||||
console.log("🔍 getVersionStatus called with:", {
|
||||
versionError,
|
||||
versionInfo,
|
||||
versionLoading,
|
||||
});
|
||||
|
||||
if (versionError) {
|
||||
console.log("❌ Version error detected:", versionError);
|
||||
return {
|
||||
status: "error",
|
||||
message: "Failed to load version info",
|
||||
Icon: AlertCircle,
|
||||
color: "text-red-600",
|
||||
};
|
||||
}
|
||||
|
||||
if (!versionInfo || versionLoading) {
|
||||
console.log("⏳ Loading state:", { versionInfo, versionLoading });
|
||||
return {
|
||||
status: "loading",
|
||||
message: "Loading version info...",
|
||||
Icon: RefreshCw,
|
||||
color: "text-gray-600",
|
||||
};
|
||||
}
|
||||
|
||||
// Use the backend's updateStatus for proper semver comparison
|
||||
switch (versionInfo.updateStatus) {
|
||||
case "update-available":
|
||||
return {
|
||||
status: "update-available",
|
||||
message: `Update available: ${versionInfo.latestVersion}`,
|
||||
Icon: Clock,
|
||||
color: "text-yellow-600",
|
||||
};
|
||||
case "newer-version":
|
||||
return {
|
||||
status: "newer-version",
|
||||
message: `Newer version running: ${versionInfo.currentVersion}`,
|
||||
Icon: CheckCircle,
|
||||
color: "text-blue-600",
|
||||
};
|
||||
case "up-to-date":
|
||||
return {
|
||||
status: "up-to-date",
|
||||
message: `Up to date: ${versionInfo.latestVersion}`,
|
||||
Icon: CheckCircle,
|
||||
color: "text-green-600",
|
||||
};
|
||||
case "no-agent":
|
||||
return {
|
||||
status: "no-agent",
|
||||
message: "No agent binary found",
|
||||
Icon: AlertCircle,
|
||||
color: "text-orange-600",
|
||||
};
|
||||
case "github-unavailable":
|
||||
return {
|
||||
status: "github-unavailable",
|
||||
message: `Agent running: ${versionInfo.currentVersion} (GitHub API unavailable)`,
|
||||
Icon: CheckCircle,
|
||||
color: "text-purple-600",
|
||||
};
|
||||
case "no-data":
|
||||
return {
|
||||
status: "no-data",
|
||||
message: "No version data available",
|
||||
Icon: AlertCircle,
|
||||
color: "text-gray-600",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
status: "unknown",
|
||||
message: "Version status unknown",
|
||||
Icon: AlertCircle,
|
||||
color: "text-gray-600",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const versionStatus = getVersionStatus();
|
||||
const StatusIcon = versionStatus.Icon;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-center mb-2">
|
||||
<Code className="h-6 w-6 text-primary-600 mr-3" />
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
Agent File Management
|
||||
</h2>
|
||||
{/* 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>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
Manage the PatchMon agent script file used for installations and
|
||||
updates
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const url = "/api/v1/hosts/agent/download";
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "patchmon-agent.sh";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Replace Script
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-secondary-900 dark:text-white mb-2">
|
||||
Agent Version Management
|
||||
</h2>
|
||||
<p className="text-secondary-600 dark:text-secondary-400">
|
||||
Monitor and manage agent versions across your infrastructure
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{agentFileLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
) : agentFileError ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-red-600 dark:text-red-400">
|
||||
Error loading agent file: {agentFileError.message}
|
||||
</p>
|
||||
</div>
|
||||
) : !agentFileInfo?.exists ? (
|
||||
<div className="text-center py-8">
|
||||
<Code className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500 dark:text-secondary-300">
|
||||
No agent script found
|
||||
</p>
|
||||
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
|
||||
Upload an agent script to get started
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Agent File Info */}
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
Current Agent Script
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
||||
Version:
|
||||
</span>
|
||||
<span className="text-sm text-secondary-900 dark:text-white font-mono">
|
||||
{agentFileInfo.version}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
||||
Size:
|
||||
</span>
|
||||
<span className="text-sm text-secondary-900 dark:text-white">
|
||||
{agentFileInfo.sizeFormatted}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
|
||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
||||
Modified:
|
||||
</span>
|
||||
<span className="text-sm text-secondary-900 dark:text-white">
|
||||
{new Date(agentFileInfo.lastModified).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage Instructions */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<Shield className="h-5 w-5 text-blue-400 dark:text-blue-300" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200">
|
||||
Agent Script Usage
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
<p className="mb-2">This script is used for:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>New agent installations via the install script</li>
|
||||
<li>
|
||||
Agent downloads from the /api/v1/hosts/agent/download
|
||||
endpoint
|
||||
</li>
|
||||
<li>Manual agent deployments and updates</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Uninstall Instructions */}
|
||||
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<Shield className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Agent Uninstall Command
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||
<p className="mb-2">
|
||||
To completely remove PatchMon from a host:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
|
||||
curl {getCurlFlags()} {window.location.origin}
|
||||
/api/v1/hosts/remove | sudo bash
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const command = `curl ${getCurlFlags()} ${window.location.origin}/api/v1/hosts/remove | sudo bash`;
|
||||
navigator.clipboard.writeText(command);
|
||||
// You could add a toast notification here
|
||||
}}
|
||||
className="px-2 py-1 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs">
|
||||
⚠️ This will remove all PatchMon files, configuration, and
|
||||
crontab entries
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Upload Modal */}
|
||||
{showUploadModal && (
|
||||
<AgentUploadModal
|
||||
isOpen={showUploadModal}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
onSubmit={uploadAgentMutation.mutate}
|
||||
isLoading={uploadAgentMutation.isPending}
|
||||
error={uploadAgentMutation.error}
|
||||
scriptFileId={scriptFileId}
|
||||
scriptContentId={scriptContentId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Agent Upload Modal Component
|
||||
const AgentUploadModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
error,
|
||||
scriptFileId,
|
||||
scriptContentId,
|
||||
}) => {
|
||||
const [scriptContent, setScriptContent] = useState("");
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
setUploadError("");
|
||||
|
||||
if (!scriptContent.trim()) {
|
||||
setUploadError("Script content is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scriptContent.trim().startsWith("#!/")) {
|
||||
setUploadError(
|
||||
"Script must start with a shebang (#!/bin/bash or #!/bin/sh)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(scriptContent);
|
||||
};
|
||||
|
||||
const handleFileUpload = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
setScriptContent(event.target.result);
|
||||
setUploadError("");
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Replace Agent Script
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
||||
{/* Status Banner */}
|
||||
<div
|
||||
className={`rounded-xl shadow-sm p-6 border-2 ${
|
||||
versionStatus.status === "up-to-date"
|
||||
? "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800"
|
||||
: versionStatus.status === "update-available"
|
||||
? "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800"
|
||||
: versionStatus.status === "no-agent"
|
||||
? "bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800"
|
||||
: "bg-white dark:bg-secondary-800 border-secondary-200 dark:border-secondary-600"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div
|
||||
className={`p-3 rounded-lg ${
|
||||
versionStatus.status === "up-to-date"
|
||||
? "bg-green-100 dark:bg-green-800"
|
||||
: versionStatus.status === "update-available"
|
||||
? "bg-yellow-100 dark:bg-yellow-800"
|
||||
: versionStatus.status === "no-agent"
|
||||
? "bg-orange-100 dark:bg-orange-800"
|
||||
: "bg-secondary-100 dark:bg-secondary-700"
|
||||
}`}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
{StatusIcon && (
|
||||
<StatusIcon className={`h-6 w-6 ${versionStatus.color}`} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={scriptFileId}
|
||||
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
|
||||
>
|
||||
Upload Script File
|
||||
</label>
|
||||
<input
|
||||
id={scriptFileId}
|
||||
type="file"
|
||||
accept=".sh"
|
||||
onChange={handleFileUpload}
|
||||
className="block w-full text-sm text-secondary-500 dark:text-secondary-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900 dark:file:text-primary-200"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Select a .sh file to upload, or paste the script content below
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-1">
|
||||
{versionStatus.message}
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
{versionStatus.status === "up-to-date" &&
|
||||
"All agent binaries are current"}
|
||||
{versionStatus.status === "update-available" &&
|
||||
"A newer version is available for download"}
|
||||
{versionStatus.status === "no-agent" &&
|
||||
"Download agent binaries to get started"}
|
||||
{versionStatus.status === "github-unavailable" &&
|
||||
"Cannot check for updates at this time"}
|
||||
{![
|
||||
"up-to-date",
|
||||
"update-available",
|
||||
"no-agent",
|
||||
"github-unavailable",
|
||||
].includes(versionStatus.status) &&
|
||||
"Version information unavailable"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={scriptContentId}
|
||||
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
|
||||
>
|
||||
Script Content *
|
||||
</label>
|
||||
<textarea
|
||||
id={scriptContentId}
|
||||
value={scriptContent}
|
||||
onChange={(e) => {
|
||||
setScriptContent(e.target.value);
|
||||
setUploadError("");
|
||||
}}
|
||||
rows={15}
|
||||
className="block w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm"
|
||||
placeholder="#!/bin/bash # PatchMon Agent Script VERSION="1.0.0" # Your script content here..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(uploadError || error) && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">
|
||||
{uploadError ||
|
||||
error?.response?.data?.error ||
|
||||
error?.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mr-2 mt-0.5" />
|
||||
<div className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<p className="font-medium">Important:</p>
|
||||
<ul className="mt-1 list-disc list-inside space-y-1">
|
||||
<li>This will replace the current agent script file</li>
|
||||
<li>A backup will be created automatically</li>
|
||||
<li>All new installations will use this script</li>
|
||||
<li>
|
||||
Existing agents will download this version on their next
|
||||
update
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button type="button" onClick={onClose} className="btn-outline">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !scriptContent.trim()}
|
||||
className="btn-primary"
|
||||
>
|
||||
{isLoading ? "Uploading..." : "Replace Script"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => checkUpdatesMutation.mutate()}
|
||||
disabled={checkUpdatesMutation.isPending}
|
||||
className="flex items-center px-4 py-2 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 rounded-lg hover:bg-secondary-50 dark:hover:bg-secondary-600 border border-secondary-300 dark:border-secondary-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm hover:shadow"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-2 ${checkUpdatesMutation.isPending ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{checkUpdatesMutation.isPending
|
||||
? "Checking..."
|
||||
: "Check for Updates"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version Information Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Current Version Card */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-sm p-6 border border-secondary-200 dark:border-secondary-600 hover:shadow-md transition-shadow duration-200">
|
||||
<h4 className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-2">
|
||||
Current Version
|
||||
</h4>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
{versionInfo?.currentVersion || (
|
||||
<span className="text-lg text-secondary-400 dark:text-secondary-500">
|
||||
Not detected
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Latest Version Card */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-sm p-6 border border-secondary-200 dark:border-secondary-600 hover:shadow-md transition-shadow duration-200">
|
||||
<h4 className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-2">
|
||||
Latest Available
|
||||
</h4>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
{versionInfo?.latestVersion || (
|
||||
<span className="text-lg text-secondary-400 dark:text-secondary-500">
|
||||
Unknown
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Last Checked Card */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-sm p-6 border border-secondary-200 dark:border-secondary-600 hover:shadow-md transition-shadow duration-200">
|
||||
<h4 className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-2">
|
||||
Last Checked
|
||||
</h4>
|
||||
<p className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
{versionInfo?.lastChecked
|
||||
? new Date(versionInfo.lastChecked).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "Never"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download Updates Section */}
|
||||
<div className="bg-gradient-to-br from-primary-50 to-blue-50 dark:from-secondary-800 dark:to-secondary-800 rounded-xl shadow-sm p-8 border border-primary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-bold text-secondary-900 dark:text-white mb-3">
|
||||
{!versionInfo?.currentVersion
|
||||
? "Get Started with Agent Binaries"
|
||||
: versionStatus.status === "update-available"
|
||||
? "New Agent Version Available"
|
||||
: "Agent Binaries"}
|
||||
</h3>
|
||||
<p className="text-secondary-700 dark:text-secondary-300 mb-4">
|
||||
{!versionInfo?.currentVersion
|
||||
? "No agent binaries detected. Download from GitHub to begin managing your agents."
|
||||
: versionStatus.status === "update-available"
|
||||
? `A new agent version (${versionInfo.latestVersion}) is available. Download the latest binaries from GitHub.`
|
||||
: "Download or redownload agent binaries from GitHub."}
|
||||
</p>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadUpdateMutation.mutate()}
|
||||
disabled={downloadUpdateMutation.isPending}
|
||||
className="flex items-center px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-md hover:shadow-lg font-medium"
|
||||
>
|
||||
{downloadUpdateMutation.isPending ? (
|
||||
<>
|
||||
<RefreshCw className="h-5 w-5 mr-2 animate-spin" />
|
||||
Downloading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-5 w-5 mr-2" />
|
||||
{!versionInfo?.currentVersion
|
||||
? "Download Binaries"
|
||||
: versionStatus.status === "update-available"
|
||||
? "Download New Agent Version"
|
||||
: "Redownload Binaries"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/PatchMon/PatchMon-agent/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center px-4 py-3 text-secondary-700 dark:text-secondary-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200 font-medium"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Supported Architectures */}
|
||||
{versionInfo?.supportedArchitectures &&
|
||||
versionInfo.supportedArchitectures.length > 0 && (
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-sm p-6 border border-secondary-200 dark:border-secondary-600">
|
||||
<h4 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
|
||||
Supported Architectures
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{versionInfo.supportedArchitectures.map((arch) => (
|
||||
<div
|
||||
key={arch}
|
||||
className="flex items-center justify-center px-4 py-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg border border-secondary-200 dark:border-secondary-600"
|
||||
>
|
||||
<code className="text-sm font-mono text-secondary-700 dark:text-secondary-300">
|
||||
{arch}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -446,6 +446,53 @@ const AgentUpdatesTab = () => {
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Uninstall Instructions */}
|
||||
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<Shield className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Agent Uninstall Command
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||
<p className="mb-3">To completely remove PatchMon from a host:</p>
|
||||
|
||||
{/* Go Agent Uninstall */}
|
||||
<div className="mb-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
|
||||
sudo patchmon-agent uninstall
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
"sudo patchmon-agent uninstall",
|
||||
);
|
||||
}}
|
||||
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 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs">
|
||||
⚠️ This command will remove all PatchMon files, configuration,
|
||||
and crontab entries
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, Image, RotateCcw, Upload, X } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Image,
|
||||
Palette,
|
||||
RotateCcw,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { THEME_PRESETS, useColorTheme } from "../../contexts/ColorThemeContext";
|
||||
import { settingsAPI } from "../../utils/api";
|
||||
|
||||
const BrandingTab = () => {
|
||||
@@ -12,6 +20,7 @@ const BrandingTab = () => {
|
||||
});
|
||||
const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
|
||||
const [selectedLogoType, setSelectedLogoType] = useState("dark");
|
||||
const { colorTheme, setColorTheme } = useColorTheme();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -75,6 +84,22 @@ const BrandingTab = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Theme update mutation
|
||||
const updateThemeMutation = useMutation({
|
||||
mutationFn: (theme) => settingsAPI.update({ colorTheme: theme }),
|
||||
onSuccess: (_data, theme) => {
|
||||
queryClient.invalidateQueries(["settings"]);
|
||||
setColorTheme(theme);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Update theme error:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleThemeChange = (theme) => {
|
||||
updateThemeMutation.mutate(theme);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -102,17 +127,110 @@ const BrandingTab = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<Image className="h-6 w-6 text-primary-600 mr-3" />
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
Logo & Branding
|
||||
</h2>
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center mb-6">
|
||||
<Image className="h-6 w-6 text-primary-600 mr-3" />
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
Logo & Branding
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
|
||||
Customize your PatchMon installation with custom logos, favicon, and
|
||||
color themes. These will be displayed throughout the application.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Color Theme Selector */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center mb-4">
|
||||
<Palette className="h-5 w-5 text-primary-600 mr-2" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Color Theme
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
|
||||
Choose a color theme that will be applied to the login page and
|
||||
background areas throughout the app.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{Object.entries(THEME_PRESETS).map(([themeKey, theme]) => {
|
||||
const isSelected = colorTheme === themeKey;
|
||||
const gradientColors = theme.login.xColors;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={themeKey}
|
||||
type="button"
|
||||
onClick={() => handleThemeChange(themeKey)}
|
||||
disabled={updateThemeMutation.isPending}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all ${
|
||||
isSelected
|
||||
? "border-primary-500 ring-2 ring-primary-200 dark:ring-primary-800"
|
||||
: "border-secondary-200 dark:border-secondary-600 hover:border-primary-300"
|
||||
} ${updateThemeMutation.isPending ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
{/* Theme Preview */}
|
||||
<div
|
||||
className="h-20 rounded-md mb-3 overflow-hidden"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${gradientColors.join(", ")})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Theme Name */}
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white mb-1">
|
||||
{theme.name}
|
||||
</div>
|
||||
|
||||
{/* Selected Indicator */}
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-label="Selected theme"
|
||||
>
|
||||
<title>Selected</title>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{updateThemeMutation.isPending && (
|
||||
<div className="mt-4 flex items-center gap-2 text-sm text-primary-600 dark:text-primary-400">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
Updating theme...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateThemeMutation.isError && (
|
||||
<div className="mt-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">
|
||||
Failed to update theme: {updateThemeMutation.error?.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logo Section Header */}
|
||||
<div className="flex items-center mb-4">
|
||||
<Image className="h-5 w-5 text-primary-600 mr-2" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Logos
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
|
||||
Customize your PatchMon installation with custom logos and favicon.
|
||||
These will be displayed throughout the application.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Dark Logo */}
|
||||
|
||||
@@ -54,7 +54,7 @@ const UsersTab = () => {
|
||||
});
|
||||
|
||||
// Update user mutation
|
||||
const _updateUserMutation = useMutation({
|
||||
const updateUserMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => adminUsersAPI.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["users"]);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { AUTH_PHASES, isAuthPhase } from "../constants/authPhases";
|
||||
import { isCorsError } from "../utils/api";
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
@@ -120,9 +121,50 @@ export const AuthProvider = ({ children }) => {
|
||||
|
||||
return { success: true };
|
||||
} else {
|
||||
// Handle HTTP error responses (like 500 CORS errors)
|
||||
console.log("HTTP error response:", response.status, data);
|
||||
|
||||
// Check if this is a CORS error based on the response data
|
||||
if (
|
||||
data.message?.includes("Not allowed by CORS") ||
|
||||
data.message?.includes("CORS") ||
|
||||
data.error?.includes("CORS")
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: data.error || "Login failed" };
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.log("Login error:", error);
|
||||
console.log("Error response:", error.response);
|
||||
console.log("Error message:", error.message);
|
||||
|
||||
// Check for CORS/network errors first
|
||||
if (isCorsError(error)) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
|
||||
};
|
||||
}
|
||||
|
||||
// Check for other network errors
|
||||
if (
|
||||
error.name === "TypeError" &&
|
||||
error.message?.includes("Failed to fetch")
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: "Network error occurred" };
|
||||
}
|
||||
};
|
||||
@@ -167,9 +209,46 @@ export const AuthProvider = ({ children }) => {
|
||||
localStorage.setItem("user", JSON.stringify(data.user));
|
||||
return { success: true, user: data.user };
|
||||
} else {
|
||||
// Handle HTTP error responses (like 500 CORS errors)
|
||||
console.log("HTTP error response:", response.status, data);
|
||||
|
||||
// Check if this is a CORS error based on the response data
|
||||
if (
|
||||
data.message?.includes("Not allowed by CORS") ||
|
||||
data.message?.includes("CORS") ||
|
||||
data.error?.includes("CORS")
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: data.error || "Update failed" };
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// Check for CORS/network errors first
|
||||
if (isCorsError(error)) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
|
||||
};
|
||||
}
|
||||
|
||||
// Check for other network errors
|
||||
if (
|
||||
error.name === "TypeError" &&
|
||||
error.message?.includes("Failed to fetch")
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: "Network error occurred" };
|
||||
}
|
||||
};
|
||||
@@ -190,12 +269,49 @@ export const AuthProvider = ({ children }) => {
|
||||
if (response.ok) {
|
||||
return { success: true };
|
||||
} else {
|
||||
// Handle HTTP error responses (like 500 CORS errors)
|
||||
console.log("HTTP error response:", response.status, data);
|
||||
|
||||
// Check if this is a CORS error based on the response data
|
||||
if (
|
||||
data.message?.includes("Not allowed by CORS") ||
|
||||
data.message?.includes("CORS") ||
|
||||
data.error?.includes("CORS")
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: data.error || "Password change failed",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// Check for CORS/network errors first
|
||||
if (isCorsError(error)) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
|
||||
};
|
||||
}
|
||||
|
||||
// Check for other network errors
|
||||
if (
|
||||
error.name === "TypeError" &&
|
||||
error.message?.includes("Failed to fetch")
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: "Network error occurred" };
|
||||
}
|
||||
};
|
||||
|
||||
194
frontend/src/contexts/ColorThemeContext.jsx
Normal file
194
frontend/src/contexts/ColorThemeContext.jsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
const ColorThemeContext = createContext();
|
||||
|
||||
// Theme configurations matching the login backgrounds
|
||||
export const THEME_PRESETS = {
|
||||
default: {
|
||||
name: "Normal Dark",
|
||||
login: {
|
||||
cellSize: 90,
|
||||
variance: 0.85,
|
||||
xColors: ["#0f172a", "#1e293b", "#334155", "#475569", "#64748b"],
|
||||
yColors: ["#0f172a", "#1e293b", "#334155", "#475569", "#64748b"],
|
||||
},
|
||||
app: {
|
||||
bgPrimary: "#1e293b",
|
||||
bgSecondary: "#1e293b",
|
||||
bgTertiary: "#334155",
|
||||
borderColor: "#475569",
|
||||
cardBg: "#1e293b",
|
||||
cardBorder: "#334155",
|
||||
buttonBg: "#334155",
|
||||
buttonHover: "#475569",
|
||||
},
|
||||
},
|
||||
cyber_blue: {
|
||||
name: "Cyber Blue",
|
||||
login: {
|
||||
cellSize: 90,
|
||||
variance: 0.85,
|
||||
xColors: ["#0a0820", "#1a1f3a", "#2d3561", "#4a5584", "#667eaf"],
|
||||
yColors: ["#0a0820", "#1a1f3a", "#2d3561", "#4a5584", "#667eaf"],
|
||||
},
|
||||
app: {
|
||||
bgPrimary: "#0a0820",
|
||||
bgSecondary: "#1a1f3a",
|
||||
bgTertiary: "#2d3561",
|
||||
borderColor: "#4a5584",
|
||||
cardBg: "#1a1f3a",
|
||||
cardBorder: "#2d3561",
|
||||
buttonBg: "#2d3561",
|
||||
buttonHover: "#4a5584",
|
||||
},
|
||||
},
|
||||
neon_purple: {
|
||||
name: "Neon Purple",
|
||||
login: {
|
||||
cellSize: 80,
|
||||
variance: 0.9,
|
||||
xColors: ["#0f0a1e", "#1e0f3e", "#4a0082", "#7209b7", "#b5179e"],
|
||||
yColors: ["#0f0a1e", "#1e0f3e", "#4a0082", "#7209b7", "#b5179e"],
|
||||
},
|
||||
app: {
|
||||
bgPrimary: "#0f0a1e",
|
||||
bgSecondary: "#1e0f3e",
|
||||
bgTertiary: "#4a0082",
|
||||
borderColor: "#7209b7",
|
||||
cardBg: "#1e0f3e",
|
||||
cardBorder: "#4a0082",
|
||||
buttonBg: "#4a0082",
|
||||
buttonHover: "#7209b7",
|
||||
},
|
||||
},
|
||||
matrix_green: {
|
||||
name: "Matrix Green",
|
||||
login: {
|
||||
cellSize: 70,
|
||||
variance: 0.7,
|
||||
xColors: ["#001a00", "#003300", "#004d00", "#006600", "#00b300"],
|
||||
yColors: ["#001a00", "#003300", "#004d00", "#006600", "#00b300"],
|
||||
},
|
||||
app: {
|
||||
bgPrimary: "#001a00",
|
||||
bgSecondary: "#003300",
|
||||
bgTertiary: "#004d00",
|
||||
borderColor: "#006600",
|
||||
cardBg: "#003300",
|
||||
cardBorder: "#004d00",
|
||||
buttonBg: "#004d00",
|
||||
buttonHover: "#006600",
|
||||
},
|
||||
},
|
||||
ocean_blue: {
|
||||
name: "Ocean Blue",
|
||||
login: {
|
||||
cellSize: 85,
|
||||
variance: 0.8,
|
||||
xColors: ["#001845", "#023e7d", "#0077b6", "#0096c7", "#00b4d8"],
|
||||
yColors: ["#001845", "#023e7d", "#0077b6", "#0096c7", "#00b4d8"],
|
||||
},
|
||||
app: {
|
||||
bgPrimary: "#001845",
|
||||
bgSecondary: "#023e7d",
|
||||
bgTertiary: "#0077b6",
|
||||
borderColor: "#0096c7",
|
||||
cardBg: "#023e7d",
|
||||
cardBorder: "#0077b6",
|
||||
buttonBg: "#0077b6",
|
||||
buttonHover: "#0096c7",
|
||||
},
|
||||
},
|
||||
sunset_gradient: {
|
||||
name: "Sunset Gradient",
|
||||
login: {
|
||||
cellSize: 95,
|
||||
variance: 0.75,
|
||||
xColors: ["#1a0033", "#330066", "#4d0099", "#6600cc", "#9933ff"],
|
||||
yColors: ["#1a0033", "#660033", "#990033", "#cc0066", "#ff0099"],
|
||||
},
|
||||
app: {
|
||||
bgPrimary: "#1a0033",
|
||||
bgSecondary: "#330066",
|
||||
bgTertiary: "#4d0099",
|
||||
borderColor: "#6600cc",
|
||||
cardBg: "#330066",
|
||||
cardBorder: "#4d0099",
|
||||
buttonBg: "#4d0099",
|
||||
buttonHover: "#6600cc",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ColorThemeProvider = ({ children }) => {
|
||||
const [colorTheme, setColorTheme] = useState("default");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Fetch theme from settings on mount
|
||||
useEffect(() => {
|
||||
const fetchTheme = async () => {
|
||||
try {
|
||||
// Check localStorage first for unauthenticated pages (login)
|
||||
const cachedTheme = localStorage.getItem("colorTheme");
|
||||
if (cachedTheme) {
|
||||
setColorTheme(cachedTheme);
|
||||
}
|
||||
|
||||
// Try to fetch from API (will fail on login page, that's ok)
|
||||
try {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
const response = await fetch("/api/v1/settings", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.color_theme) {
|
||||
setColorTheme(data.color_theme);
|
||||
localStorage.setItem("colorTheme", data.color_theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_apiError) {
|
||||
// Silent fail - use cached or default theme
|
||||
console.log("Could not fetch theme from API, using cached/default");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading color theme:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTheme();
|
||||
}, []);
|
||||
|
||||
const updateColorTheme = (theme) => {
|
||||
setColorTheme(theme);
|
||||
localStorage.setItem("colorTheme", theme);
|
||||
};
|
||||
|
||||
const value = {
|
||||
colorTheme,
|
||||
setColorTheme: updateColorTheme,
|
||||
themeConfig: THEME_PRESETS[colorTheme] || THEME_PRESETS.default,
|
||||
isLoading,
|
||||
};
|
||||
|
||||
return (
|
||||
<ColorThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ColorThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useColorTheme = () => {
|
||||
const context = useContext(ColorThemeContext);
|
||||
if (!context) {
|
||||
throw new Error("useColorTheme must be used within ColorThemeProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -9,7 +9,7 @@
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-secondary-50 dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 antialiased;
|
||||
@apply bg-secondary-50 dark:bg-transparent text-secondary-900 dark:text-secondary-100 antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,19 +39,46 @@
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply btn border-secondary-300 dark:border-secondary-600 text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-800 hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-secondary-500;
|
||||
@apply btn border-secondary-300 text-secondary-700 bg-white hover:bg-secondary-50 focus:ring-secondary-500;
|
||||
}
|
||||
|
||||
.dark .btn-outline {
|
||||
background-color: var(--theme-button-bg, #1e293b);
|
||||
border-color: var(--card-border, #334155);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dark .btn-outline:hover {
|
||||
background-color: var(--theme-button-hover, #334155);
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white dark:bg-secondary-800 rounded-lg shadow-card dark:shadow-card-dark border border-secondary-200 dark:border-secondary-600;
|
||||
@apply bg-white rounded-lg shadow-card border border-secondary-200;
|
||||
}
|
||||
|
||||
.dark .card {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
border-color: var(--card-border, #334155);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply card hover:shadow-card-hover transition-shadow duration-150;
|
||||
@apply card transition-all duration-150;
|
||||
}
|
||||
|
||||
.dark .card-hover:hover {
|
||||
background-color: var(--card-bg-hover, #334155);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply block w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100;
|
||||
@apply block w-full px-3 py-2 border border-secondary-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white text-secondary-900;
|
||||
}
|
||||
|
||||
.dark .input {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
border-color: var(--card-border, #334155);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.label {
|
||||
@@ -84,6 +111,27 @@
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Theme-aware backgrounds for general elements */
|
||||
.dark .bg-secondary-800 {
|
||||
background-color: var(--card-bg, #1e293b) !important;
|
||||
}
|
||||
|
||||
.dark .bg-secondary-700 {
|
||||
background-color: var(--card-bg-hover, #334155) !important;
|
||||
}
|
||||
|
||||
.dark .bg-secondary-900 {
|
||||
background-color: var(--theme-button-bg, #1e293b) !important;
|
||||
}
|
||||
|
||||
.dark .border-secondary-600 {
|
||||
border-color: var(--card-border, #334155) !important;
|
||||
}
|
||||
|
||||
.dark .border-secondary-700 {
|
||||
border-color: var(--theme-button-hover, #475569) !important;
|
||||
}
|
||||
|
||||
.text-shadow {
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
Bot,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
XCircle,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import api from "../utils/api";
|
||||
|
||||
const Automation = () => {
|
||||
@@ -33,7 +30,7 @@ const Automation = () => {
|
||||
});
|
||||
|
||||
// Fetch queue statistics
|
||||
const { data: queueStats, isLoading: statsLoading } = useQuery({
|
||||
useQuery({
|
||||
queryKey: ["automation-stats"],
|
||||
queryFn: async () => {
|
||||
const response = await api.get("/automation/stats");
|
||||
@@ -43,7 +40,7 @@ const Automation = () => {
|
||||
});
|
||||
|
||||
// Fetch recent jobs
|
||||
const { data: recentJobs, isLoading: jobsLoading } = useQuery({
|
||||
useQuery({
|
||||
queryKey: ["automation-jobs"],
|
||||
queryFn: async () => {
|
||||
const jobs = await Promise.all([
|
||||
@@ -62,7 +59,7 @@ const Automation = () => {
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
const _getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||
@@ -75,7 +72,7 @@ const Automation = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const _getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "bg-green-100 text-green-800";
|
||||
@@ -88,12 +85,12 @@ const Automation = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const _formatDate = (dateString) => {
|
||||
if (!dateString) return "N/A";
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const formatDuration = (ms) => {
|
||||
const _formatDuration = (ms) => {
|
||||
if (!ms) return "N/A";
|
||||
return `${ms}ms`;
|
||||
};
|
||||
@@ -127,8 +124,9 @@ const Automation = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getNextRunTime = (schedule, lastRun) => {
|
||||
const getNextRunTime = (schedule, _lastRun) => {
|
||||
if (schedule === "Manual only") return "Manual trigger only";
|
||||
if (schedule.includes("Agent-driven")) return "Agent-driven (automatic)";
|
||||
if (schedule === "Daily at midnight") {
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
@@ -157,6 +155,34 @@ const Automation = () => {
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
if (schedule === "Daily at 3 AM") {
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(3, 0, 0, 0);
|
||||
return tomorrow.toLocaleString([], {
|
||||
hour12: true,
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
if (schedule === "Daily at 4 AM") {
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(4, 0, 0, 0);
|
||||
return tomorrow.toLocaleString([], {
|
||||
hour12: true,
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
if (schedule === "Every hour") {
|
||||
const now = new Date();
|
||||
const nextHour = new Date(now);
|
||||
@@ -175,6 +201,7 @@ const Automation = () => {
|
||||
|
||||
const getNextRunTimestamp = (schedule) => {
|
||||
if (schedule === "Manual only") return Number.MAX_SAFE_INTEGER; // Manual tasks go to bottom
|
||||
if (schedule.includes("Agent-driven")) return Number.MAX_SAFE_INTEGER - 1; // Agent-driven tasks near bottom but above manual
|
||||
if (schedule === "Daily at midnight") {
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
@@ -189,6 +216,20 @@ const Automation = () => {
|
||||
tomorrow.setHours(2, 0, 0, 0);
|
||||
return tomorrow.getTime();
|
||||
}
|
||||
if (schedule === "Daily at 3 AM") {
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(3, 0, 0, 0);
|
||||
return tomorrow.getTime();
|
||||
}
|
||||
if (schedule === "Daily at 4 AM") {
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(4, 0, 0, 0);
|
||||
return tomorrow.getTime();
|
||||
}
|
||||
if (schedule === "Every hour") {
|
||||
const now = new Date();
|
||||
const nextHour = new Date(now);
|
||||
@@ -198,6 +239,45 @@ const Automation = () => {
|
||||
return Number.MAX_SAFE_INTEGER; // Unknown schedules go to bottom
|
||||
};
|
||||
|
||||
const openBullBoard = () => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
alert("Please log in to access the Queue Monitor");
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the proxied URL through the frontend (port 3000)
|
||||
// This avoids CORS issues as everything goes through the same origin
|
||||
const url = `/bullboard?token=${encodeURIComponent(token)}`;
|
||||
// Open in a new tab instead of a new window
|
||||
const bullBoardWindow = window.open(url, "_blank");
|
||||
|
||||
// Add a message listener to handle authentication failures
|
||||
if (bullBoardWindow) {
|
||||
// Listen for authentication failures and refresh with token
|
||||
const checkAuth = () => {
|
||||
try {
|
||||
// Check if the Bull Board window is still open
|
||||
if (bullBoardWindow.closed) return;
|
||||
|
||||
// Inject a script to handle authentication failures
|
||||
bullBoardWindow.postMessage(
|
||||
{
|
||||
type: "BULL_BOARD_TOKEN",
|
||||
token: token,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
} catch (e) {
|
||||
console.log("Could not communicate with Bull Board window:", e);
|
||||
}
|
||||
};
|
||||
|
||||
// Send token after a short delay to ensure Bull Board is loaded
|
||||
setTimeout(checkAuth, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerManualJob = async (jobType, data = {}) => {
|
||||
try {
|
||||
let endpoint;
|
||||
@@ -206,13 +286,17 @@ const Automation = () => {
|
||||
endpoint = "/automation/trigger/github-update";
|
||||
} else if (jobType === "sessions") {
|
||||
endpoint = "/automation/trigger/session-cleanup";
|
||||
} else if (jobType === "echo") {
|
||||
endpoint = "/automation/trigger/echo-hello";
|
||||
} else if (jobType === "orphaned-repos") {
|
||||
endpoint = "/automation/trigger/orphaned-repo-cleanup";
|
||||
} else if (jobType === "orphaned-packages") {
|
||||
endpoint = "/automation/trigger/orphaned-package-cleanup";
|
||||
} else if (jobType === "docker-inventory") {
|
||||
endpoint = "/automation/trigger/docker-inventory-cleanup";
|
||||
} else if (jobType === "agent-collection") {
|
||||
endpoint = "/automation/trigger/agent-collection";
|
||||
}
|
||||
|
||||
const response = await api.post(endpoint, data);
|
||||
const _response = await api.post(endpoint, data);
|
||||
|
||||
// Refresh data
|
||||
window.location.reload();
|
||||
@@ -303,34 +387,40 @@ const Automation = () => {
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => triggerManualJob("github")}
|
||||
onClick={openBullBoard}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
title="Trigger manual GitHub update check"
|
||||
title="Open Bull Board Queue Monitor"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Check Updates
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => triggerManualJob("sessions")}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
title="Trigger manual session cleanup"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Clean Sessions
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
triggerManualJob("echo", {
|
||||
message: "Hello from Automation Page!",
|
||||
})
|
||||
}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
title="Trigger echo hello task"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Echo Hello
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 36 36"
|
||||
role="img"
|
||||
aria-label="Bull Board"
|
||||
>
|
||||
<circle fill="#DD2E44" cx="18" cy="18" r="18" />
|
||||
<circle fill="#FFF" cx="18" cy="18" r="13.5" />
|
||||
<circle fill="#DD2E44" cx="18" cy="18" r="10" />
|
||||
<circle fill="#FFF" cx="18" cy="18" r="6" />
|
||||
<circle fill="#DD2E44" cx="18" cy="18" r="3" />
|
||||
<path
|
||||
opacity=".2"
|
||||
d="M18.24 18.282l13.144 11.754s-2.647 3.376-7.89 5.109L17.579 18.42l.661-.138z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFAC33"
|
||||
d="M18.294 19a.994.994 0 01-.704-1.699l.563-.563a.995.995 0 011.408 1.407l-.564.563a.987.987 0 01-.703.292z"
|
||||
/>
|
||||
<path
|
||||
fill="#55ACEE"
|
||||
d="M24.016 6.981c-.403 2.079 0 4.691 0 4.691l7.054-7.388c.291-1.454-.528-3.932-1.718-4.238-1.19-.306-4.079.803-5.336 6.935zm5.003 5.003c-2.079.403-4.691 0-4.691 0l7.388-7.054c1.454-.291 3.932.528 4.238 1.718.306 1.19-.803 4.079-6.935 5.336z"
|
||||
/>
|
||||
<path
|
||||
fill="#3A87C2"
|
||||
d="M32.798 4.485L21.176 17.587c-.362.362-1.673.882-2.51.046-.836-.836-.419-2.08-.057-2.443L31.815 3.501s.676-.635 1.159-.152-.176 1.136-.176 1.136z"
|
||||
/>
|
||||
</svg>
|
||||
Queue Monitor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -509,14 +599,22 @@ const Automation = () => {
|
||||
triggerManualJob("github");
|
||||
} else if (automation.queue.includes("session")) {
|
||||
triggerManualJob("sessions");
|
||||
} else if (automation.queue.includes("echo")) {
|
||||
triggerManualJob("echo", {
|
||||
message: "Manual trigger from table",
|
||||
});
|
||||
} else if (
|
||||
automation.queue.includes("orphaned-repo")
|
||||
) {
|
||||
triggerManualJob("orphaned-repos");
|
||||
} else if (
|
||||
automation.queue.includes("orphaned-package")
|
||||
) {
|
||||
triggerManualJob("orphaned-packages");
|
||||
} else if (
|
||||
automation.queue.includes("docker-inventory")
|
||||
) {
|
||||
triggerManualJob("docker-inventory");
|
||||
} else if (
|
||||
automation.queue.includes("agent-commands")
|
||||
) {
|
||||
triggerManualJob("agent-collection");
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center justify-center w-6 h-6 border border-transparent rounded text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-colors duration-200"
|
||||
@@ -525,20 +623,7 @@ const Automation = () => {
|
||||
<Play className="h-3 w-3" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (automation.queue.includes("echo")) {
|
||||
triggerManualJob("echo", {
|
||||
message: "Manual trigger from table",
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center justify-center w-6 h-6 border border-transparent rounded text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-colors duration-200"
|
||||
title="Trigger"
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="text-gray-400 text-xs">Manual</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
|
||||
@@ -200,6 +200,8 @@ const Dashboard = () => {
|
||||
data: packageTrendsData,
|
||||
isLoading: packageTrendsLoading,
|
||||
error: _packageTrendsError,
|
||||
refetch: refetchPackageTrends,
|
||||
isFetching: packageTrendsFetching,
|
||||
} = useQuery({
|
||||
queryKey: ["packageTrends", packageTrendsPeriod, packageTrendsHost],
|
||||
queryFn: () => {
|
||||
@@ -771,6 +773,20 @@ const Dashboard = () => {
|
||||
Package Trends Over Time
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Refresh Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetchPackageTrends()}
|
||||
disabled={packageTrendsFetching}
|
||||
className="px-3 py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
title="Refresh data"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${packageTrendsFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</button>
|
||||
|
||||
{/* Period Selector */}
|
||||
<select
|
||||
value={packageTrendsPeriod}
|
||||
@@ -1161,7 +1177,7 @@ const Dashboard = () => {
|
||||
try {
|
||||
const date = new Date(`${label}:00:00`);
|
||||
// Check if date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return label; // Return original label if date is invalid
|
||||
}
|
||||
return date.toLocaleDateString("en-US", {
|
||||
@@ -1171,7 +1187,7 @@ const Dashboard = () => {
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
return label; // Return original label if parsing fails
|
||||
}
|
||||
}
|
||||
@@ -1180,17 +1196,24 @@ const Dashboard = () => {
|
||||
try {
|
||||
const date = new Date(label);
|
||||
// Check if date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return label; // Return original label if date is invalid
|
||||
}
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
return label; // Return original label if parsing fails
|
||||
}
|
||||
},
|
||||
label: (context) => {
|
||||
const value = context.parsed.y;
|
||||
if (value === null || value === undefined) {
|
||||
return `${context.dataset.label}: No data`;
|
||||
}
|
||||
return `${context.dataset.label}: ${value}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1222,7 +1245,7 @@ const Dashboard = () => {
|
||||
const hourNum = parseInt(hour, 10);
|
||||
|
||||
// Validate hour number
|
||||
if (isNaN(hourNum) || hourNum < 0 || hourNum > 23) {
|
||||
if (Number.isNaN(hourNum) || hourNum < 0 || hourNum > 23) {
|
||||
return hour; // Return original hour if invalid
|
||||
}
|
||||
|
||||
@@ -1233,7 +1256,7 @@ const Dashboard = () => {
|
||||
: hourNum === 12
|
||||
? "12 PM"
|
||||
: `${hourNum - 12} PM`;
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
return label; // Return original label if parsing fails
|
||||
}
|
||||
}
|
||||
@@ -1242,14 +1265,14 @@ const Dashboard = () => {
|
||||
try {
|
||||
const date = new Date(label);
|
||||
// Check if date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return label; // Return original label if date is invalid
|
||||
}
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
return label; // Return original label if parsing fails
|
||||
}
|
||||
},
|
||||
@@ -1411,7 +1434,6 @@ const Dashboard = () => {
|
||||
title="Customize dashboard layout"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Customize Dashboard
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1423,7 +1445,6 @@ const Dashboard = () => {
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{isFetching ? "Refreshing..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Search,
|
||||
Server,
|
||||
Shield,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -18,12 +19,15 @@ import { Link } from "react-router-dom";
|
||||
import api from "../utils/api";
|
||||
|
||||
const Docker = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [activeTab, setActiveTab] = useState("containers");
|
||||
const [sortField, setSortField] = useState("status");
|
||||
const [sortDirection, setSortDirection] = useState("asc");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [sourceFilter, setSourceFilter] = useState("all");
|
||||
const [deleteContainerModal, setDeleteContainerModal] = useState(null);
|
||||
const [deleteImageModal, setDeleteImageModal] = useState(null);
|
||||
|
||||
// Fetch Docker dashboard data
|
||||
const { data: dashboard, isLoading: dashboardLoading } = useQuery({
|
||||
@@ -36,7 +40,11 @@ const Docker = () => {
|
||||
});
|
||||
|
||||
// Fetch containers
|
||||
const { data: containersData, isLoading: containersLoading } = useQuery({
|
||||
const {
|
||||
data: containersData,
|
||||
isLoading: containersLoading,
|
||||
refetch: refetchContainers,
|
||||
} = useQuery({
|
||||
queryKey: ["docker", "containers", statusFilter],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -49,7 +57,11 @@ const Docker = () => {
|
||||
});
|
||||
|
||||
// Fetch images
|
||||
const { data: imagesData, isLoading: imagesLoading } = useQuery({
|
||||
const {
|
||||
data: imagesData,
|
||||
isLoading: imagesLoading,
|
||||
refetch: refetchImages,
|
||||
} = useQuery({
|
||||
queryKey: ["docker", "images", sourceFilter],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -81,6 +93,42 @@ const Docker = () => {
|
||||
enabled: activeTab === "updates",
|
||||
});
|
||||
|
||||
// Delete container mutation
|
||||
const deleteContainerMutation = useMutation({
|
||||
mutationFn: async (containerId) => {
|
||||
const response = await api.delete(`/docker/containers/${containerId}`);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["docker", "containers"]);
|
||||
queryClient.invalidateQueries(["docker", "dashboard"]);
|
||||
setDeleteContainerModal(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
alert(
|
||||
`Failed to delete container: ${error.response?.data?.error || error.message}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Delete image mutation
|
||||
const deleteImageMutation = useMutation({
|
||||
mutationFn: async (imageId) => {
|
||||
const response = await api.delete(`/docker/images/${imageId}`);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["docker", "images"]);
|
||||
queryClient.invalidateQueries(["docker", "dashboard"]);
|
||||
setDeleteImageModal(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
alert(
|
||||
`Failed to delete image: ${error.response?.data?.error || error.message}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Filter and sort containers
|
||||
const filteredContainers = useMemo(() => {
|
||||
if (!containersData?.containers) return [];
|
||||
@@ -288,32 +336,36 @@ const Docker = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
|
||||
Docker Inventory
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Monitor containers, images, and updates across your infrastructure
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Trigger refresh of all queries
|
||||
window.location.reload();
|
||||
}}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Trigger refresh based on active tab
|
||||
if (activeTab === "containers") refetchContainers();
|
||||
else if (activeTab === "images") refetchImages();
|
||||
else window.location.reload();
|
||||
}}
|
||||
className="btn-outline flex items-center justify-center p-2"
|
||||
title="Refresh data"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Cards */}
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
@@ -400,11 +452,11 @@ const Docker = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs and Content */}
|
||||
<div className="card">
|
||||
{/* Docker List */}
|
||||
<div className="card flex-1 flex flex-col overflow-hidden min-h-0">
|
||||
{/* Tab Navigation */}
|
||||
<div className="border-b border-secondary-200 dark:border-secondary-700">
|
||||
<nav className="-mb-px flex space-x-8 px-6" aria-label="Tabs">
|
||||
<div className="border-b border-secondary-200 dark:border-secondary-600">
|
||||
<nav className="-mb-px flex space-x-8 px-4" aria-label="Tabs">
|
||||
{[
|
||||
{ id: "containers", label: "Containers", icon: Container },
|
||||
{ id: "images", label: "Images", icon: Package },
|
||||
@@ -443,7 +495,7 @@ const Docker = () => {
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="p-6 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<div className="p-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
@@ -498,7 +550,7 @@ const Docker = () => {
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">
|
||||
<div className="p-4 flex-1 overflow-auto">
|
||||
{/* Containers Tab */}
|
||||
{activeTab === "containers" && (
|
||||
<div className="overflow-x-auto">
|
||||
@@ -522,83 +574,80 @@ const Docker = () => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-900">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
|
||||
onClick={() => handleSort("name")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("name")}
|
||||
className="flex items-center gap-2 hover:text-secondary-700"
|
||||
>
|
||||
Container Name
|
||||
{getSortIcon("name")}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
|
||||
onClick={() => handleSort("image")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("image")}
|
||||
className="flex items-center gap-2 hover:text-secondary-700"
|
||||
>
|
||||
Image
|
||||
{getSortIcon("image")}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
|
||||
onClick={() => handleSort("status")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("status")}
|
||||
className="flex items-center gap-2 hover:text-secondary-700"
|
||||
>
|
||||
Status
|
||||
{getSortIcon("status")}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
|
||||
onClick={() => handleSort("host")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("host")}
|
||||
className="flex items-center gap-2 hover:text-secondary-700"
|
||||
>
|
||||
Host
|
||||
{getSortIcon("host")}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{filteredContainers.map((container) => (
|
||||
<tr
|
||||
key={container.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Container className="h-5 w-5 text-secondary-400 mr-3" />
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Container className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
|
||||
<Link
|
||||
to={`/docker/containers/${container.id}`}
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate"
|
||||
>
|
||||
{container.name}
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<td className="px-4 py-2">
|
||||
<div className="text-sm text-secondary-900 dark:text-white">
|
||||
{container.image_name}:{container.image_tag}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center">
|
||||
{getStatusBadge(container.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
<Link
|
||||
to={`/hosts/${container.host_id}`}
|
||||
className="text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
@@ -608,14 +657,24 @@ const Docker = () => {
|
||||
"Unknown"}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link
|
||||
to={`/docker/containers/${container.id}`}
|
||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
|
||||
>
|
||||
View
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Link
|
||||
to={`/docker/containers/${container.id}`}
|
||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center gap-1"
|
||||
title="View details"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteContainerModal(container)}
|
||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 inline-flex items-center"
|
||||
title="Delete container from inventory"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -648,88 +707,79 @@ const Docker = () => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-900">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
|
||||
onClick={() => handleSort("repository")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("repository")}
|
||||
className="flex items-center gap-2 hover:text-secondary-700"
|
||||
>
|
||||
Repository
|
||||
{getSortIcon("repository")}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
|
||||
onClick={() => handleSort("tag")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("tag")}
|
||||
className="flex items-center gap-2 hover:text-secondary-700"
|
||||
>
|
||||
Tag
|
||||
{getSortIcon("tag")}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Source
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
|
||||
onClick={() => handleSort("containers")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("containers")}
|
||||
className="flex items-center gap-2 hover:text-secondary-700"
|
||||
>
|
||||
Containers
|
||||
{getSortIcon("containers")}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Updates
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{filteredImages.map((image) => (
|
||||
<tr
|
||||
key={image.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Package className="h-5 w-5 text-secondary-400 mr-3" />
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
|
||||
<Link
|
||||
to={`/docker/images/${image.id}`}
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate"
|
||||
>
|
||||
{image.repository}
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
|
||||
{image.tag}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center">
|
||||
{getSourceBadge(image.source)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-secondary-900 dark:text-white">
|
||||
{image._count?.docker_containers || 0}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center">
|
||||
{image.hasUpdates ? (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
@@ -741,14 +791,24 @@ const Docker = () => {
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link
|
||||
to={`/docker/images/${image.id}`}
|
||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
|
||||
>
|
||||
View
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Link
|
||||
to={`/docker/images/${image.id}`}
|
||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
|
||||
title="View details"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteImageModal(image)}
|
||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 inline-flex items-center"
|
||||
title="Delete image from inventory"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -781,86 +841,80 @@ const Docker = () => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-900">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
|
||||
onClick={() => handleSort("name")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("name")}
|
||||
className="flex items-center gap-2 hover:text-secondary-700"
|
||||
>
|
||||
Host Name
|
||||
{getSortIcon("name")}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
|
||||
onClick={() => handleSort("containers")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("containers")}
|
||||
className="flex items-center gap-2 hover:text-secondary-700"
|
||||
>
|
||||
Containers
|
||||
{getSortIcon("containers")}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Running
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
|
||||
onClick={() => handleSort("images")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("images")}
|
||||
className="flex items-center gap-2 hover:text-secondary-700"
|
||||
>
|
||||
Images
|
||||
{getSortIcon("images")}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{filteredHosts.map((host) => (
|
||||
<tr
|
||||
key={host.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Server className="h-5 w-5 text-secondary-400 mr-3" />
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
|
||||
<Link
|
||||
to={`/docker/hosts/${host.id}`}
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate"
|
||||
>
|
||||
{host.friendly_name || host.hostname}
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-secondary-900 dark:text-white">
|
||||
{host.dockerStats?.totalContainers || 0}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-green-600 dark:text-green-400">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-green-600 dark:text-green-400 font-medium">
|
||||
{host.dockerStats?.runningContainers || 0}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-secondary-900 dark:text-white">
|
||||
{host.dockerStats?.totalImages || 0}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center">
|
||||
<Link
|
||||
to={`/docker/hosts/${host.id}`}
|
||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
|
||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center gap-1"
|
||||
title="View details"
|
||||
>
|
||||
View
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -892,82 +946,64 @@ const Docker = () => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-900">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Image
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Tag
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Detection Method
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Affected
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{updatesData.updates.map((update) => (
|
||||
<tr
|
||||
key={update.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Package className="h-5 w-5 text-secondary-400 mr-3" />
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
|
||||
<Link
|
||||
to={`/docker/images/${update.image_id}`}
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate"
|
||||
>
|
||||
{update.docker_images?.repository}
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
|
||||
{update.current_tag}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
<Package className="h-3 w-3 mr-1" />
|
||||
Digest Comparison
|
||||
Digest
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
Update Available
|
||||
Available
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
{update.affectedContainersCount} container
|
||||
{update.affectedContainersCount !== 1 ? "s" : ""}
|
||||
{update.affectedHosts?.length > 0 && (
|
||||
@@ -978,13 +1014,13 @@ const Docker = () => {
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center">
|
||||
<Link
|
||||
to={`/docker/images/${update.image_id}`}
|
||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
|
||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center gap-1"
|
||||
title="View details"
|
||||
>
|
||||
View
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -996,6 +1032,141 @@ const Docker = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Container Modal */}
|
||||
{deleteContainerModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-start mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertTriangle className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Delete Container
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-secondary-600 dark:text-secondary-300">
|
||||
<p className="mb-2">
|
||||
Are you sure you want to delete this container from the
|
||||
inventory?
|
||||
</p>
|
||||
<div className="bg-secondary-100 dark:bg-secondary-700 p-3 rounded-md">
|
||||
<p className="font-medium text-secondary-900 dark:text-white">
|
||||
{deleteContainerModal.name}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Image: {deleteContainerModal.image_name}:
|
||||
{deleteContainerModal.image_tag}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400">
|
||||
Host:{" "}
|
||||
{deleteContainerModal.host?.friendly_name || "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-3 text-red-600 dark:text-red-400 font-medium">
|
||||
⚠️ This only removes the container from PatchMon's inventory.
|
||||
It does NOT stop or delete the actual Docker container on
|
||||
the host.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
deleteContainerMutation.mutate(deleteContainerModal.id)
|
||||
}
|
||||
disabled={deleteContainerMutation.isPending}
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{deleteContainerMutation.isPending
|
||||
? "Deleting..."
|
||||
: "Delete from Inventory"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteContainerModal(null)}
|
||||
disabled={deleteContainerMutation.isPending}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-700 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Image Modal */}
|
||||
{deleteImageModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-start mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertTriangle className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Delete Image
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-secondary-600 dark:text-secondary-300">
|
||||
<p className="mb-2">
|
||||
Are you sure you want to delete this image from the
|
||||
inventory?
|
||||
</p>
|
||||
<div className="bg-secondary-100 dark:bg-secondary-700 p-3 rounded-md">
|
||||
<p className="font-medium text-secondary-900 dark:text-white">
|
||||
{deleteImageModal.repository}:{deleteImageModal.tag}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Source: {deleteImageModal.source}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400">
|
||||
Containers using this:{" "}
|
||||
{deleteImageModal._count?.docker_containers || 0}
|
||||
</p>
|
||||
</div>
|
||||
{deleteImageModal._count?.docker_containers > 0 ? (
|
||||
<p className="mt-3 text-red-600 dark:text-red-400 font-medium">
|
||||
⚠️ Cannot delete: This image is in use by{" "}
|
||||
{deleteImageModal._count.docker_containers} container(s).
|
||||
Delete the containers first.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-3 text-red-600 dark:text-red-400 font-medium">
|
||||
⚠️ This only removes the image from PatchMon's inventory.
|
||||
It does NOT delete the actual Docker image from hosts.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteImageMutation.mutate(deleteImageModal.id)}
|
||||
disabled={
|
||||
deleteImageMutation.isPending ||
|
||||
deleteImageModal._count?.docker_containers > 0
|
||||
}
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{deleteImageMutation.isPending
|
||||
? "Deleting..."
|
||||
: "Delete from Inventory"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteImageModal(null)}
|
||||
disabled={deleteImageMutation.isPending}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-700 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Clock3,
|
||||
Copy,
|
||||
Cpu,
|
||||
Database,
|
||||
@@ -27,11 +30,13 @@ import {
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import InlineEdit from "../components/InlineEdit";
|
||||
import InlineMultiGroupEdit from "../components/InlineMultiGroupEdit";
|
||||
import {
|
||||
adminHostsAPI,
|
||||
dashboardAPI,
|
||||
formatDate,
|
||||
formatRelativeTime,
|
||||
hostGroupsAPI,
|
||||
repositoryAPI,
|
||||
settingsAPI,
|
||||
} from "../utils/api";
|
||||
@@ -46,6 +51,8 @@ const HostDetail = () => {
|
||||
const [activeTab, setActiveTab] = useState("host");
|
||||
const [historyPage, setHistoryPage] = useState(0);
|
||||
const [historyLimit] = useState(10);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [notesMessage, setNotesMessage] = useState({ text: "", type: "" });
|
||||
|
||||
const {
|
||||
data: host,
|
||||
@@ -66,6 +73,64 @@ const HostDetail = () => {
|
||||
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||
});
|
||||
|
||||
// WebSocket connection status using Server-Sent Events (SSE) for real-time push updates
|
||||
const [wsStatus, setWsStatus] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!host?.api_id) return;
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) return;
|
||||
|
||||
let eventSource = null;
|
||||
let reconnectTimeout = null;
|
||||
let isMounted = true;
|
||||
|
||||
const connect = () => {
|
||||
if (!isMounted) return;
|
||||
|
||||
try {
|
||||
// Create EventSource for SSE connection
|
||||
eventSource = new EventSource(
|
||||
`/api/v1/ws/status/${host.api_id}/stream?token=${encodeURIComponent(token)}`,
|
||||
);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
setWsStatus(data);
|
||||
} catch (_err) {
|
||||
// Silently handle parse errors
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (_error) => {
|
||||
console.log(`[SSE] Connection error for ${host.api_id}, retrying...`);
|
||||
eventSource?.close();
|
||||
|
||||
// Automatic reconnection after 5 seconds
|
||||
if (isMounted) {
|
||||
reconnectTimeout = setTimeout(connect, 5000);
|
||||
}
|
||||
};
|
||||
} catch (_err) {
|
||||
// Silently handle connection errors
|
||||
}
|
||||
};
|
||||
|
||||
// Initial connection
|
||||
connect();
|
||||
|
||||
// Cleanup on unmount or when api_id changes
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (reconnectTimeout) clearTimeout(reconnectTimeout);
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
};
|
||||
}, [host?.api_id]);
|
||||
|
||||
// Fetch repository count for this host
|
||||
const { data: repositories, isLoading: isLoadingRepos } = useQuery({
|
||||
queryKey: ["host-repositories", hostId],
|
||||
@@ -75,6 +140,14 @@ const HostDetail = () => {
|
||||
enabled: !!hostId,
|
||||
});
|
||||
|
||||
// Fetch host groups for multi-select
|
||||
const { data: hostGroups } = useQuery({
|
||||
queryKey: ["host-groups"],
|
||||
queryFn: () => hostGroupsAPI.list().then((res) => res.data),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - data stays fresh longer
|
||||
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||
});
|
||||
|
||||
// Tab change handler
|
||||
const handleTabChange = (tabName) => {
|
||||
setActiveTab(tabName);
|
||||
@@ -87,6 +160,13 @@ const HostDetail = () => {
|
||||
}
|
||||
}, [host]);
|
||||
|
||||
// Sync notes state with host data
|
||||
useEffect(() => {
|
||||
if (host) {
|
||||
setNotes(host.notes || "");
|
||||
}
|
||||
}, [host]);
|
||||
|
||||
const deleteHostMutation = useMutation({
|
||||
mutationFn: (hostId) => adminHostsAPI.delete(hostId),
|
||||
onSuccess: () => {
|
||||
@@ -118,12 +198,32 @@ const HostDetail = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const updateHostGroupsMutation = useMutation({
|
||||
mutationFn: ({ hostId, groupIds }) =>
|
||||
adminHostsAPI.updateGroups(hostId, groupIds).then((res) => res.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["host", hostId]);
|
||||
queryClient.invalidateQueries(["hosts"]);
|
||||
},
|
||||
});
|
||||
|
||||
const updateNotesMutation = useMutation({
|
||||
mutationFn: ({ hostId, notes }) =>
|
||||
adminHostsAPI.updateNotes(hostId, notes).then((res) => res.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["host", hostId]);
|
||||
queryClient.invalidateQueries(["hosts"]);
|
||||
setNotesMessage({ text: "Notes saved successfully!", type: "success" });
|
||||
// Clear message after 3 seconds
|
||||
setTimeout(() => setNotesMessage({ text: "", type: "" }), 3000);
|
||||
},
|
||||
onError: (error) => {
|
||||
setNotesMessage({
|
||||
text: error.response?.data?.error || "Failed to save notes",
|
||||
type: "error",
|
||||
});
|
||||
// Clear message after 5 seconds for errors
|
||||
setTimeout(() => setNotesMessage({ text: "", type: "" }), 5000);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -238,49 +338,67 @@ const HostDetail = () => {
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4 pb-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-start justify-between mb-4 pb-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-start gap-3">
|
||||
<Link
|
||||
to="/hosts"
|
||||
className="text-secondary-500 hover:text-secondary-700 dark:text-secondary-400 dark:hover:text-secondary-200"
|
||||
className="text-secondary-500 hover:text-secondary-700 dark:text-secondary-400 dark:hover:text-secondary-200 mt-1"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h1 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{host.friendly_name}
|
||||
</h1>
|
||||
{host.system_uptime && (
|
||||
<div className="flex items-center gap-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="text-xs font-medium">Uptime:</span>
|
||||
<span>{host.system_uptime}</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Title row with friendly name, badge, and status */}
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
|
||||
{host.friendly_name}
|
||||
</h1>
|
||||
{wsStatus && (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase ${
|
||||
wsStatus.connected
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 animate-pulse"
|
||||
: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
||||
}`}
|
||||
title={
|
||||
wsStatus.connected
|
||||
? `Agent connected via ${wsStatus.secure ? "WSS (secure)" : "WS"}`
|
||||
: "Agent not connected"
|
||||
}
|
||||
>
|
||||
{wsStatus.connected
|
||||
? wsStatus.secure
|
||||
? "WSS"
|
||||
: "WS"
|
||||
: "Offline"}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={`flex items-center gap-2 px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(isStale, host.stats.outdated_packages > 0)}`}
|
||||
>
|
||||
{getStatusIcon(isStale, host.stats.outdated_packages > 0)}
|
||||
{getStatusText(isStale, host.stats.outdated_packages > 0)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Info row with uptime and last updated */}
|
||||
<div className="flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
{host.system_uptime && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">Uptime:</span>
|
||||
<span className="text-xs">{host.system_uptime}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">Last updated:</span>
|
||||
<span className="text-xs">
|
||||
{formatRelativeTime(host.last_update)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="text-xs font-medium">Last updated:</span>
|
||||
<span>{formatRelativeTime(host.last_update)}</span>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-2 px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(isStale, host.stats.outdated_packages > 0)}`}
|
||||
>
|
||||
{getStatusIcon(isStale, host.stats.outdated_packages > 0)}
|
||||
{getStatusText(isStale, host.stats.outdated_packages > 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="btn-outline flex items-center gap-2 text-sm"
|
||||
title="Refresh host data"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{isFetching ? "Refreshing..." : "Refresh"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCredentialsModal(true)}
|
||||
@@ -289,13 +407,24 @@ const HostDetail = () => {
|
||||
<Key className="h-4 w-4" />
|
||||
Deploy Agent
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="btn-outline flex items-center justify-center p-2 text-sm"
|
||||
title="Refresh host data"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
className="btn-danger flex items-center gap-2 text-sm"
|
||||
className="btn-danger flex items-center justify-center p-2 text-sm"
|
||||
title="Delete host"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -426,7 +555,18 @@ const HostDetail = () => {
|
||||
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300"
|
||||
}`}
|
||||
>
|
||||
Agent History
|
||||
Package Reports
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabChange("queue")}
|
||||
className={`px-4 py-2 text-sm font-medium ${
|
||||
activeTab === "queue"
|
||||
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500"
|
||||
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300"
|
||||
}`}
|
||||
>
|
||||
Agent Queue
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -493,20 +633,30 @@ const HostDetail = () => {
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
|
||||
Host Group
|
||||
Host Groups
|
||||
</p>
|
||||
{host.host_groups ? (
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium text-white"
|
||||
style={{ backgroundColor: host.host_groups.color }}
|
||||
>
|
||||
{host.host_groups.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-200">
|
||||
Ungrouped
|
||||
</span>
|
||||
)}
|
||||
{/* Extract group IDs from the new many-to-many structure */}
|
||||
{(() => {
|
||||
const groupIds =
|
||||
host.host_group_memberships?.map(
|
||||
(membership) => membership.host_groups.id,
|
||||
) || [];
|
||||
return (
|
||||
<InlineMultiGroupEdit
|
||||
key={`${host.id}-${groupIds.join(",")}`}
|
||||
value={groupIds}
|
||||
onSave={(newGroupIds) =>
|
||||
updateHostGroupsMutation.mutate({
|
||||
hostId: host.id,
|
||||
groupIds: newGroupIds,
|
||||
})
|
||||
}
|
||||
options={hostGroups || []}
|
||||
placeholder="Select groups..."
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1095,14 +1245,41 @@ const HostDetail = () => {
|
||||
Host Notes
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Success/Error Message */}
|
||||
{notesMessage.text && (
|
||||
<div
|
||||
className={`rounded-md p-4 ${
|
||||
notesMessage.type === "success"
|
||||
? "bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700"
|
||||
: "bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex">
|
||||
{notesMessage.type === "success" ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
|
||||
) : (
|
||||
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||
)}
|
||||
<div className="ml-3">
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
notesMessage.type === "success"
|
||||
? "text-green-800 dark:text-green-200"
|
||||
: "text-red-800 dark:text-red-200"
|
||||
}`}
|
||||
>
|
||||
{notesMessage.text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-4">
|
||||
<textarea
|
||||
value={host.notes || ""}
|
||||
onChange={(e) => {
|
||||
// Update local state immediately for better UX
|
||||
const updatedHost = { ...host, notes: e.target.value };
|
||||
queryClient.setQueryData(["host", hostId], updatedHost);
|
||||
}}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Add notes about this host... (e.g., purpose, special configurations, maintenance notes)"
|
||||
className="w-full h-32 p-3 border border-secondary-200 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-none"
|
||||
maxLength={1000}
|
||||
@@ -1114,14 +1291,14 @@ const HostDetail = () => {
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-secondary-400 dark:text-secondary-500">
|
||||
{(host.notes || "").length}/1000
|
||||
{notes.length}/1000
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateNotesMutation.mutate({
|
||||
hostId: host.id,
|
||||
notes: host.notes || "",
|
||||
notes: notes,
|
||||
});
|
||||
}}
|
||||
disabled={updateNotesMutation.isPending}
|
||||
@@ -1136,6 +1313,9 @@ const HostDetail = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Queue */}
|
||||
{activeTab === "queue" && <AgentQueueTab hostId={hostId} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1168,8 +1348,10 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("quick-install");
|
||||
const [forceInstall, setForceInstall] = useState(false);
|
||||
const [architecture, setArchitecture] = useState("amd64");
|
||||
const apiIdInputId = useId();
|
||||
const apiKeyInputId = useId();
|
||||
const architectureSelectId = useId();
|
||||
|
||||
const { data: serverUrlData } = useQuery({
|
||||
queryKey: ["serverUrl"],
|
||||
@@ -1189,10 +1371,13 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
|
||||
};
|
||||
|
||||
// Helper function to build installation URL with optional force flag
|
||||
// Helper function to build installation URL with optional force flag and architecture
|
||||
const getInstallUrl = () => {
|
||||
const baseUrl = `${serverUrl}/api/v1/hosts/install`;
|
||||
return forceInstall ? `${baseUrl}?force=true` : baseUrl;
|
||||
const params = new URLSearchParams();
|
||||
if (forceInstall) params.append("force", "true");
|
||||
params.append("arch", architecture);
|
||||
return `${baseUrl}?${params.toString()}`;
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text) => {
|
||||
@@ -1308,6 +1493,29 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Architecture Selection */}
|
||||
<div className="mb-3">
|
||||
<label
|
||||
htmlFor={architectureSelectId}
|
||||
className="block text-sm font-medium text-primary-800 dark:text-primary-200 mb-2"
|
||||
>
|
||||
Target Architecture
|
||||
</label>
|
||||
<select
|
||||
id={architectureSelectId}
|
||||
value={architecture}
|
||||
onChange={(e) => setArchitecture(e.target.value)}
|
||||
className="px-3 py-2 border border-primary-300 dark:border-primary-600 rounded-md bg-white dark:bg-secondary-800 text-sm text-secondary-900 dark:text-white focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="amd64">AMD64 (x86_64) - Default</option>
|
||||
<option value="386">386 (i386) - 32-bit</option>
|
||||
<option value="arm64">ARM64 (aarch64) - ARM</option>
|
||||
</select>
|
||||
<p className="text-xs text-primary-600 dark:text-primary-400 mt-1">
|
||||
Select the architecture of the target host
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
@@ -1364,12 +1572,12 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
|
||||
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
|
||||
2. Download and Install Agent Script
|
||||
2. Download and Install Agent Binary
|
||||
</h5>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={`curl ${getCurlFlags()} -o /usr/local/bin/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" && sudo chmod +x /usr/local/bin/patchmon-agent.sh`}
|
||||
value={`curl ${getCurlFlags()} -o /usr/local/bin/patchmon-agent ${serverUrl}/api/v1/hosts/agent/download?arch=${architecture} -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" && sudo chmod +x /usr/local/bin/patchmon-agent`}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
|
||||
/>
|
||||
@@ -1377,7 +1585,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
`curl ${getCurlFlags()} -o /usr/local/bin/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" && sudo chmod +x /usr/local/bin/patchmon-agent.sh`,
|
||||
`curl ${getCurlFlags()} -o /usr/local/bin/patchmon-agent ${serverUrl}/api/v1/hosts/agent/download?arch=${architecture} -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" && sudo chmod +x /usr/local/bin/patchmon-agent`,
|
||||
)
|
||||
}
|
||||
className="btn-secondary flex items-center gap-1"
|
||||
@@ -1395,7 +1603,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={`sudo /usr/local/bin/patchmon-agent.sh configure "${host.api_id}" "${host.api_key}" "${serverUrl}"`}
|
||||
value={`sudo /usr/local/bin/patchmon-agent config set-api "${host.api_id}" "${host.api_key}" "${serverUrl}"`}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
|
||||
/>
|
||||
@@ -1403,7 +1611,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
`sudo /usr/local/bin/patchmon-agent.sh configure "${host.api_id}" "${host.api_key}" "${serverUrl}"`,
|
||||
`sudo /usr/local/bin/patchmon-agent config set-api "${host.api_id}" "${host.api_key}" "${serverUrl}"`,
|
||||
)
|
||||
}
|
||||
className="btn-secondary flex items-center gap-1"
|
||||
@@ -1421,7 +1629,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value="sudo /usr/local/bin/patchmon-agent.sh test"
|
||||
value="sudo /usr/local/bin/patchmon-agent ping"
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
|
||||
/>
|
||||
@@ -1429,7 +1637,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
"sudo /usr/local/bin/patchmon-agent.sh test",
|
||||
"sudo /usr/local/bin/patchmon-agent ping",
|
||||
)
|
||||
}
|
||||
className="btn-secondary flex items-center gap-1"
|
||||
@@ -1447,7 +1655,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value="sudo /usr/local/bin/patchmon-agent.sh update"
|
||||
value="sudo /usr/local/bin/patchmon-agent report"
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
|
||||
/>
|
||||
@@ -1455,7 +1663,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
"sudo /usr/local/bin/patchmon-agent.sh update",
|
||||
"sudo /usr/local/bin/patchmon-agent report",
|
||||
)
|
||||
}
|
||||
className="btn-secondary flex items-center gap-1"
|
||||
@@ -1468,12 +1676,33 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
|
||||
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
|
||||
6. Setup Crontab (Optional)
|
||||
6. Create Systemd Service File
|
||||
</h5>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={`(sudo crontab -l 2>/dev/null | grep -v "patchmon-agent.sh update"; echo "${new Date().getMinutes()} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1") | sudo crontab -`}
|
||||
value={`sudo tee /etc/systemd/system/patchmon-agent.service > /dev/null << 'EOF'
|
||||
[Unit]
|
||||
Description=PatchMon Agent Service
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/local/bin/patchmon-agent serve
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
WorkingDirectory=/etc/patchmon
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=patchmon-agent
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF`}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
|
||||
/>
|
||||
@@ -1481,7 +1710,28 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
`(sudo crontab -l 2>/dev/null | grep -v "patchmon-agent.sh update"; echo "${new Date().getMinutes()} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1") | sudo crontab -`,
|
||||
`sudo tee /etc/systemd/system/patchmon-agent.service > /dev/null << 'EOF'
|
||||
[Unit]
|
||||
Description=PatchMon Agent Service
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/local/bin/patchmon-agent serve
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
WorkingDirectory=/etc/patchmon
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=patchmon-agent
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF`,
|
||||
)
|
||||
}
|
||||
className="btn-secondary flex items-center gap-1"
|
||||
@@ -1491,6 +1741,64 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
|
||||
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
|
||||
7. Enable and Start Service
|
||||
</h5>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value="sudo systemctl daemon-reload && sudo systemctl enable patchmon-agent && sudo systemctl start patchmon-agent"
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
"sudo systemctl daemon-reload && sudo systemctl enable patchmon-agent && sudo systemctl start patchmon-agent",
|
||||
)
|
||||
}
|
||||
className="btn-secondary flex items-center gap-1"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-2">
|
||||
This will start the agent service and establish WebSocket
|
||||
connection for real-time communication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
|
||||
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
|
||||
8. Verify Service Status
|
||||
</h5>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value="sudo systemctl status patchmon-agent"
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyToClipboard("sudo systemctl status patchmon-agent")
|
||||
}
|
||||
className="btn-secondary flex items-center gap-1"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-2">
|
||||
Check that the service is running and WebSocket connection
|
||||
is established
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1659,4 +1967,249 @@ const DeleteConfirmationModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Agent Queue Tab Component
|
||||
const AgentQueueTab = ({ hostId }) => {
|
||||
const {
|
||||
data: queueData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["host-queue", hostId],
|
||||
queryFn: () => dashboardAPI.getHostQueue(hostId).then((res) => res.data),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
refetchInterval: 30 * 1000, // Auto-refresh every 30 seconds
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-primary-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||
<p className="text-red-600 dark:text-red-400">
|
||||
Failed to load queue data
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="mt-2 px-4 py-2 text-sm bg-primary-600 text-white rounded-md hover:bg-primary-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { waiting, active, delayed, failed, jobHistory } = queueData.data;
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||
case "failed":
|
||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
||||
case "active":
|
||||
return <Clock3 className="h-4 w-4 text-blue-500" />;
|
||||
default:
|
||||
return <Clock className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "text-green-600 dark:text-green-400";
|
||||
case "failed":
|
||||
return "text-red-600 dark:text-red-400";
|
||||
case "active":
|
||||
return "text-blue-600 dark:text-blue-400";
|
||||
default:
|
||||
return "text-gray-600 dark:text-gray-400";
|
||||
}
|
||||
};
|
||||
|
||||
const formatJobType = (type) => {
|
||||
switch (type) {
|
||||
case "settings_update":
|
||||
return "Settings Update";
|
||||
case "report_now":
|
||||
return "Report Now";
|
||||
case "update_agent":
|
||||
return "Agent Update";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Live Agent Queue Status
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
title="Refresh queue data"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Queue Summary */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<Server className="h-5 w-5 text-blue-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Waiting
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{waiting}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<Clock3 className="h-5 w-5 text-warning-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Active
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{active}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<Clock className="h-5 w-5 text-primary-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Delayed
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{delayed}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<AlertCircle className="h-5 w-5 text-danger-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Failed
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{failed}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Job History */}
|
||||
<div>
|
||||
{jobHistory.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Server className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
No job history found
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Job ID
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Job Name
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Attempt
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Date/Time
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Error/Output
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{jobHistory.map((job) => (
|
||||
<tr
|
||||
key={job.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-xs font-mono text-secondary-900 dark:text-white">
|
||||
{job.job_id}
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-xs text-secondary-900 dark:text-white">
|
||||
{formatJobType(job.job_name)}
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(job.status)}
|
||||
<span
|
||||
className={`text-xs font-medium ${getStatusColor(job.status)}`}
|
||||
>
|
||||
{job.status.charAt(0).toUpperCase() +
|
||||
job.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-xs text-secondary-900 dark:text-white">
|
||||
{job.attempt_number}
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-xs text-secondary-900 dark:text-white">
|
||||
{new Date(job.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs">
|
||||
{job.error_message ? (
|
||||
<span className="text-red-600 dark:text-red-400">
|
||||
{job.error_message}
|
||||
</span>
|
||||
) : job.output ? (
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
{JSON.stringify(job.output)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-secondary-500 dark:text-secondary-400">
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HostDetail;
|
||||
|
||||
@@ -21,12 +21,13 @@ import {
|
||||
Square,
|
||||
Trash2,
|
||||
Users,
|
||||
Wifi,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import InlineEdit from "../components/InlineEdit";
|
||||
import InlineGroupEdit from "../components/InlineGroupEdit";
|
||||
import InlineMultiGroupEdit from "../components/InlineMultiGroupEdit";
|
||||
import InlineToggle from "../components/InlineToggle";
|
||||
import {
|
||||
adminHostsAPI,
|
||||
@@ -34,14 +35,14 @@ import {
|
||||
formatRelativeTime,
|
||||
hostGroupsAPI,
|
||||
} from "../utils/api";
|
||||
import { OSIcon } from "../utils/osIcons.jsx";
|
||||
import { getOSDisplayName, OSIcon } from "../utils/osIcons.jsx";
|
||||
|
||||
// Add Host Modal Component
|
||||
const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
const friendlyNameId = useId();
|
||||
const [formData, setFormData] = useState({
|
||||
friendly_name: "",
|
||||
hostGroupId: "",
|
||||
hostGroupIds: [], // Changed to array for multiple selection
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -64,7 +65,7 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
const response = await adminHostsAPI.create(formData);
|
||||
console.log("Host created successfully:", formData.friendly_name);
|
||||
onSuccess(response.data);
|
||||
setFormData({ friendly_name: "", hostGroupId: "" });
|
||||
setFormData({ friendly_name: "", hostGroupIds: [] });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Full error object:", err);
|
||||
@@ -134,68 +135,56 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-3">
|
||||
Host Group
|
||||
Host Groups
|
||||
</span>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{/* No Group Option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, hostGroupId: "" })}
|
||||
className={`flex flex-col items-center justify-center px-2 py-3 text-center border-2 rounded-lg transition-all duration-200 relative min-h-[80px] ${
|
||||
formData.hostGroupId === ""
|
||||
? "border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300"
|
||||
: "border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 hover:border-secondary-400 dark:hover:border-secondary-500"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-medium">No Group</div>
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
|
||||
Ungrouped
|
||||
</div>
|
||||
{formData.hostGroupId === "" && (
|
||||
<div className="absolute top-2 right-2 w-3 h-3 rounded-full bg-primary-500 flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-white"></div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{/* Host Group Options */}
|
||||
{hostGroups?.map((group) => (
|
||||
<button
|
||||
<label
|
||||
key={group.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFormData({ ...formData, hostGroupId: group.id })
|
||||
}
|
||||
className={`flex flex-col items-center justify-center px-2 py-3 text-center border-2 rounded-lg transition-all duration-200 relative min-h-[80px] ${
|
||||
formData.hostGroupId === group.id
|
||||
? "border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300"
|
||||
: "border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 hover:border-secondary-400 dark:hover:border-secondary-500"
|
||||
className={`flex items-center gap-3 p-3 border-2 rounded-lg transition-all duration-200 cursor-pointer ${
|
||||
formData.hostGroupIds.includes(group.id)
|
||||
? "border-primary-500 bg-primary-50 dark:bg-primary-900/30"
|
||||
: "border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 hover:border-secondary-400 dark:hover:border-secondary-500"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 mb-1 w-full justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.hostGroupIds.includes(group.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setFormData({
|
||||
...formData,
|
||||
hostGroupIds: [...formData.hostGroupIds, group.id],
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
...formData,
|
||||
hostGroupIds: formData.hostGroupIds.filter(
|
||||
(id) => id !== group.id,
|
||||
),
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 text-primary-600 bg-gray-100 border-gray-300 rounded focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{group.color && (
|
||||
<div
|
||||
className="w-3 h-3 rounded-full border border-secondary-300 dark:border-secondary-500 flex-shrink-0"
|
||||
style={{ backgroundColor: group.color }}
|
||||
></div>
|
||||
)}
|
||||
<div className="text-xs font-medium truncate max-w-full">
|
||||
<div className="text-sm font-medium text-secondary-700 dark:text-secondary-200">
|
||||
{group.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Group
|
||||
</div>
|
||||
{formData.hostGroupId === group.id && (
|
||||
<div className="absolute top-2 right-2 w-3 h-3 rounded-full bg-primary-500 flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-white"></div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
Optional: Assign this host to a group for better organization.
|
||||
Optional: Select one or more groups to assign this host to for
|
||||
better organization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -328,22 +317,24 @@ const Hosts = () => {
|
||||
const defaultConfig = [
|
||||
{ id: "select", label: "Select", visible: true, order: 0 },
|
||||
{ id: "host", label: "Friendly Name", visible: true, order: 1 },
|
||||
{ id: "ip", label: "IP Address", visible: false, order: 2 },
|
||||
{ id: "group", label: "Group", visible: true, order: 3 },
|
||||
{ id: "os", label: "OS", visible: true, order: 4 },
|
||||
{ id: "os_version", label: "OS Version", visible: false, order: 5 },
|
||||
{ id: "agent_version", label: "Agent Version", visible: true, order: 6 },
|
||||
{ id: "hostname", label: "System Hostname", visible: true, order: 2 },
|
||||
{ id: "ip", label: "IP Address", visible: false, order: 3 },
|
||||
{ id: "group", label: "Group", visible: true, order: 4 },
|
||||
{ id: "os", label: "OS", visible: true, order: 5 },
|
||||
{ id: "os_version", label: "OS Version", visible: false, order: 6 },
|
||||
{ id: "agent_version", label: "Agent Version", visible: true, order: 7 },
|
||||
{
|
||||
id: "auto_update",
|
||||
label: "Agent Auto-Update",
|
||||
visible: true,
|
||||
order: 7,
|
||||
order: 8,
|
||||
},
|
||||
{ id: "status", label: "Status", visible: true, order: 8 },
|
||||
{ id: "updates", label: "Updates", visible: true, order: 9 },
|
||||
{ id: "notes", label: "Notes", visible: false, order: 10 },
|
||||
{ id: "last_update", label: "Last Update", visible: true, order: 11 },
|
||||
{ id: "actions", label: "Actions", visible: true, order: 12 },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
const saved = localStorage.getItem("hosts-column-config");
|
||||
@@ -365,8 +356,11 @@ const Hosts = () => {
|
||||
localStorage.removeItem("hosts-column-config");
|
||||
return defaultConfig;
|
||||
} else {
|
||||
// Use the existing configuration
|
||||
return savedConfig;
|
||||
// Ensure ws_status column is visible in saved config
|
||||
const updatedConfig = savedConfig.map((col) =>
|
||||
col.id === "ws_status" ? { ...col, visible: true } : col,
|
||||
);
|
||||
return updatedConfig;
|
||||
}
|
||||
} catch {
|
||||
// If there's an error parsing the config, clear it and use default
|
||||
@@ -398,9 +392,87 @@ const Hosts = () => {
|
||||
queryFn: () => hostGroupsAPI.list().then((res) => res.data),
|
||||
});
|
||||
|
||||
// Track WebSocket status for all hosts
|
||||
const [wsStatusMap, setWsStatusMap] = useState({});
|
||||
|
||||
// Fetch initial WebSocket status for all hosts
|
||||
useEffect(() => {
|
||||
if (!hosts || hosts.length === 0) return;
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) return;
|
||||
|
||||
// Fetch initial WebSocket status for all hosts
|
||||
// Fetch initial WebSocket status for all hosts
|
||||
const fetchInitialStatus = async () => {
|
||||
const apiIds = hosts
|
||||
.filter((host) => host.api_id)
|
||||
.map((host) => host.api_id);
|
||||
|
||||
if (apiIds.length === 0) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/ws/status?apiIds=${apiIds.join(",")}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
setWsStatusMap(result.data);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Silently handle errors
|
||||
}
|
||||
};
|
||||
|
||||
fetchInitialStatus();
|
||||
}, [hosts]);
|
||||
|
||||
// Subscribe to WebSocket status changes for all hosts via polling (lightweight alternative to SSE)
|
||||
useEffect(() => {
|
||||
if (!hosts || hosts.length === 0) return;
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) return;
|
||||
|
||||
// Use polling instead of SSE to avoid connection pool issues
|
||||
// Poll every 10 seconds instead of 19 persistent connections
|
||||
const pollInterval = setInterval(() => {
|
||||
const apiIds = hosts
|
||||
.filter((host) => host.api_id)
|
||||
.map((host) => host.api_id);
|
||||
|
||||
if (apiIds.length === 0) return;
|
||||
|
||||
fetch(`/api/v1/ws/status?apiIds=${apiIds.join(",")}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setWsStatusMap(result.data);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently handle errors
|
||||
});
|
||||
}, 10000); // Poll every 10 seconds
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
clearInterval(pollInterval);
|
||||
};
|
||||
}, [hosts]);
|
||||
|
||||
const bulkUpdateGroupMutation = useMutation({
|
||||
mutationFn: ({ hostIds, hostGroupId }) =>
|
||||
adminHostsAPI.bulkUpdateGroup(hostIds, hostGroupId),
|
||||
mutationFn: ({ hostIds, groupIds }) =>
|
||||
adminHostsAPI.bulkUpdateGroups(hostIds, groupIds),
|
||||
onSuccess: (data) => {
|
||||
console.log("bulkUpdateGroupMutation success:", data);
|
||||
|
||||
@@ -411,11 +483,7 @@ const Hosts = () => {
|
||||
return oldData.map((host) => {
|
||||
const updatedHost = data.hosts.find((h) => h.id === host.id);
|
||||
if (updatedHost) {
|
||||
// Ensure hostGroupId is set correctly
|
||||
return {
|
||||
...updatedHost,
|
||||
hostGroupId: updatedHost.host_groups?.id || null,
|
||||
};
|
||||
return updatedHost;
|
||||
}
|
||||
return host;
|
||||
});
|
||||
@@ -439,7 +507,7 @@ const Hosts = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const updateHostGroupMutation = useMutation({
|
||||
const _updateHostGroupMutation = useMutation({
|
||||
mutationFn: ({ hostId, hostGroupId }) => {
|
||||
console.log("updateHostGroupMutation called with:", {
|
||||
hostId,
|
||||
@@ -485,6 +553,46 @@ const Hosts = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const updateHostGroupsMutation = useMutation({
|
||||
mutationFn: ({ hostId, groupIds }) => {
|
||||
console.log("updateHostGroupsMutation called with:", {
|
||||
hostId,
|
||||
groupIds,
|
||||
});
|
||||
return adminHostsAPI.updateGroups(hostId, groupIds).then((res) => {
|
||||
console.log("updateGroups API response:", res);
|
||||
return res.data;
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
// Update the cache with the new host data
|
||||
queryClient.setQueryData(["hosts"], (oldData) => {
|
||||
console.log("Old cache data before update:", oldData);
|
||||
if (!oldData) return oldData;
|
||||
const updatedData = oldData.map((host) => {
|
||||
if (host.id === data.host.id) {
|
||||
console.log(
|
||||
"Updating host in cache:",
|
||||
host.id,
|
||||
"with new data:",
|
||||
data.host,
|
||||
);
|
||||
return data.host;
|
||||
}
|
||||
return host;
|
||||
});
|
||||
console.log("New cache data after update:", updatedData);
|
||||
return updatedData;
|
||||
});
|
||||
|
||||
// Also invalidate to ensure consistency
|
||||
queryClient.invalidateQueries(["hosts"]);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("updateHostGroupsMutation error:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const toggleAutoUpdateMutation = useMutation({
|
||||
mutationFn: ({ hostId, autoUpdate }) =>
|
||||
adminHostsAPI
|
||||
@@ -525,8 +633,8 @@ const Hosts = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkAssign = (hostGroupId) => {
|
||||
bulkUpdateGroupMutation.mutate({ hostIds: selectedHosts, hostGroupId });
|
||||
const handleBulkAssign = (groupIds) => {
|
||||
bulkUpdateGroupMutation.mutate({ hostIds: selectedHosts, groupIds });
|
||||
};
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
@@ -562,7 +670,7 @@ const Hosts = () => {
|
||||
osFilter === "all" ||
|
||||
host.os_type?.toLowerCase() === osFilter.toLowerCase();
|
||||
|
||||
// URL filter for hosts needing updates, inactive hosts, up-to-date hosts, or stale hosts
|
||||
// URL filter for hosts needing updates, inactive hosts, up-to-date hosts, stale hosts, or offline hosts
|
||||
const filter = searchParams.get("filter");
|
||||
const matchesUrlFilter =
|
||||
(filter !== "needsUpdates" ||
|
||||
@@ -570,7 +678,8 @@ const Hosts = () => {
|
||||
(filter !== "inactive" ||
|
||||
(host.effectiveStatus || host.status) === "inactive") &&
|
||||
(filter !== "upToDate" || (!host.isStale && host.updatesCount === 0)) &&
|
||||
(filter !== "stale" || host.isStale);
|
||||
(filter !== "stale" || host.isStale) &&
|
||||
(filter !== "offline" || wsStatusMap[host.api_id]?.connected !== true);
|
||||
|
||||
// Hide stale filter
|
||||
const matchesHideStale = !hideStale || !host.isStale;
|
||||
@@ -655,6 +764,7 @@ const Hosts = () => {
|
||||
sortDirection,
|
||||
searchParams,
|
||||
hideStale,
|
||||
wsStatusMap,
|
||||
]);
|
||||
|
||||
// Get unique OS types from hosts for dynamic dropdown
|
||||
@@ -756,10 +866,19 @@ const Hosts = () => {
|
||||
{ id: "group", label: "Group", visible: true, order: 4 },
|
||||
{ id: "os", label: "OS", visible: true, order: 5 },
|
||||
{ id: "os_version", label: "OS Version", visible: false, order: 6 },
|
||||
{ id: "status", label: "Status", visible: true, order: 7 },
|
||||
{ id: "updates", label: "Updates", visible: true, order: 8 },
|
||||
{ id: "last_update", label: "Last Update", visible: true, order: 9 },
|
||||
{ id: "actions", label: "Actions", visible: true, order: 10 },
|
||||
{ id: "agent_version", label: "Agent Version", visible: true, order: 7 },
|
||||
{
|
||||
id: "auto_update",
|
||||
label: "Agent Auto-Update",
|
||||
visible: true,
|
||||
order: 8,
|
||||
},
|
||||
{ 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 },
|
||||
];
|
||||
updateColumnConfig(defaultConfig);
|
||||
};
|
||||
@@ -822,27 +941,33 @@ const Hosts = () => {
|
||||
{host.ip || "N/A"}
|
||||
</div>
|
||||
);
|
||||
case "group":
|
||||
case "group": {
|
||||
// Extract group IDs from the new many-to-many structure
|
||||
const groupIds =
|
||||
host.host_group_memberships?.map(
|
||||
(membership) => membership.host_groups.id,
|
||||
) || [];
|
||||
return (
|
||||
<InlineGroupEdit
|
||||
key={`${host.id}-${host.host_groups?.id || "ungrouped"}-${host.host_groups?.name || "ungrouped"}`}
|
||||
value={host.host_groups?.id}
|
||||
onSave={(newGroupId) =>
|
||||
updateHostGroupMutation.mutate({
|
||||
<InlineMultiGroupEdit
|
||||
key={`${host.id}-${groupIds.join(",")}`}
|
||||
value={groupIds}
|
||||
onSave={(newGroupIds) =>
|
||||
updateHostGroupsMutation.mutate({
|
||||
hostId: host.id,
|
||||
hostGroupId: newGroupId,
|
||||
groupIds: newGroupIds,
|
||||
})
|
||||
}
|
||||
options={hostGroups || []}
|
||||
placeholder="Select group..."
|
||||
placeholder="Select groups..."
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "os":
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-secondary-900 dark:text-white">
|
||||
<OSIcon osType={host.os_type} className="h-4 w-4" />
|
||||
<span>{host.os_type}</span>
|
||||
<span>{getOSDisplayName(host.os_type)}</span>
|
||||
</div>
|
||||
);
|
||||
case "os_version":
|
||||
@@ -871,6 +996,38 @@ const Hosts = () => {
|
||||
falseLabel="No"
|
||||
/>
|
||||
);
|
||||
case "ws_status": {
|
||||
const wsStatus = wsStatusMap[host.api_id];
|
||||
if (!wsStatus) {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full mr-1.5"></div>
|
||||
Unknown
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
wsStatus.connected
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
||||
}`}
|
||||
title={
|
||||
wsStatus.connected
|
||||
? `Agent connected via ${wsStatus.secure ? "WSS (secure)" : "WS (insecure)"}`
|
||||
: "Agent not connected"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full mr-1.5 ${
|
||||
wsStatus.connected ? "bg-green-500 animate-pulse" : "bg-red-500"
|
||||
}`}
|
||||
></div>
|
||||
{wsStatus.connected ? (wsStatus.secure ? "WSS" : "WS") : "Offline"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case "status":
|
||||
return (
|
||||
<div className="text-sm text-secondary-900 dark:text-white">
|
||||
@@ -966,13 +1123,13 @@ const Hosts = () => {
|
||||
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true });
|
||||
};
|
||||
|
||||
const handleStaleClick = () => {
|
||||
// Filter to show stale/inactive hosts
|
||||
setStatusFilter("inactive");
|
||||
const handleConnectionStatusClick = () => {
|
||||
// Filter to show offline hosts (not connected via WebSocket)
|
||||
setStatusFilter("all");
|
||||
setShowFilters(true);
|
||||
// We'll use the existing inactive URL filter logic
|
||||
// Use a new URL filter for connection status
|
||||
const newSearchParams = new URLSearchParams(window.location.search);
|
||||
newSearchParams.set("filter", "inactive");
|
||||
newSearchParams.set("filter", "offline");
|
||||
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true });
|
||||
};
|
||||
|
||||
@@ -1026,13 +1183,12 @@ const Hosts = () => {
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
className="btn-outline flex items-center justify-center p-2"
|
||||
title="Refresh hosts data"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{isFetching ? "Refreshing..." : "Refresh"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1102,17 +1258,46 @@ const Hosts = () => {
|
||||
<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={handleStaleClick}
|
||||
onClick={handleConnectionStatusClick}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Stale
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{hosts?.filter((h) => h.isStale).length || 0}
|
||||
<Wifi className="h-5 w-5 text-primary-600 mr-2" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-secondary-500 dark:text-white mb-1">
|
||||
Connection Status
|
||||
</p>
|
||||
{(() => {
|
||||
const connectedCount =
|
||||
hosts?.filter(
|
||||
(h) => wsStatusMap[h.api_id]?.connected === true,
|
||||
).length || 0;
|
||||
const offlineCount =
|
||||
hosts?.filter(
|
||||
(h) => wsStatusMap[h.api_id]?.connected !== true,
|
||||
).length || 0;
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{connectedCount}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Connected
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{offlineCount}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Offline
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -1437,6 +1622,11 @@ const Hosts = () => {
|
||||
<div className="flex items-center gap-2 font-normal text-xs text-secondary-500 dark:text-secondary-300 normal-case tracking-wider">
|
||||
{column.label}
|
||||
</div>
|
||||
) : column.id === "ws_status" ? (
|
||||
<div className="flex items-center gap-2 font-normal text-xs text-secondary-500 dark:text-secondary-300 normal-case tracking-wider">
|
||||
<Wifi className="h-3 w-3" />
|
||||
{column.label}
|
||||
</div>
|
||||
) : column.id === "status" ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1569,8 +1759,7 @@ const BulkAssignModal = ({
|
||||
onAssign,
|
||||
isLoading,
|
||||
}) => {
|
||||
const [selectedGroupId, setSelectedGroupId] = useState("");
|
||||
const bulkHostGroupId = useId();
|
||||
const [selectedGroupIds, setSelectedGroupIds] = useState([]);
|
||||
|
||||
// Fetch host groups for selection
|
||||
const { data: hostGroups } = useQuery({
|
||||
@@ -1584,7 +1773,17 @@ const BulkAssignModal = ({
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onAssign(selectedGroupId || null);
|
||||
onAssign(selectedGroupIds);
|
||||
};
|
||||
|
||||
const toggleGroup = (groupId) => {
|
||||
setSelectedGroupIds((prev) => {
|
||||
if (prev.includes(groupId)) {
|
||||
return prev.filter((id) => id !== groupId);
|
||||
} else {
|
||||
return [...prev, groupId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1592,7 +1791,7 @@ const BulkAssignModal = ({
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Assign to Host Group
|
||||
Assign to Host Groups
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1622,27 +1821,43 @@ const BulkAssignModal = ({
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={bulkHostGroupId}
|
||||
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1"
|
||||
>
|
||||
Host Group
|
||||
</label>
|
||||
<select
|
||||
id={bulkHostGroupId}
|
||||
value={selectedGroupId}
|
||||
onChange={(e) => setSelectedGroupId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">No group (ungrouped)</option>
|
||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-3">
|
||||
Host Groups
|
||||
</span>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{/* Host Group Options */}
|
||||
{hostGroups?.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
<label
|
||||
key={group.id}
|
||||
className={`flex items-center gap-3 p-3 border-2 rounded-lg transition-all duration-200 cursor-pointer ${
|
||||
selectedGroupIds.includes(group.id)
|
||||
? "border-primary-500 bg-primary-50 dark:bg-primary-900/30"
|
||||
: "border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 hover:border-secondary-400 dark:hover:border-secondary-500"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedGroupIds.includes(group.id)}
|
||||
onChange={() => toggleGroup(group.id)}
|
||||
className="w-4 h-4 text-primary-600 bg-gray-100 border-gray-300 rounded focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{group.color && (
|
||||
<div
|
||||
className="w-3 h-3 rounded-full border border-secondary-300 dark:border-secondary-500 flex-shrink-0"
|
||||
style={{ backgroundColor: group.color }}
|
||||
></div>
|
||||
)}
|
||||
<div className="text-sm font-medium text-secondary-700 dark:text-secondary-200">
|
||||
{group.name}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
Select a group to assign these hosts to, or leave ungrouped.
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
Select one or more groups to assign these hosts to, or leave
|
||||
ungrouped.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1656,7 +1871,7 @@ const BulkAssignModal = ({
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={isLoading}>
|
||||
{isLoading ? "Assigning..." : "Assign to Group"}
|
||||
{isLoading ? "Assigning..." : "Assign to Groups"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1785,9 +2000,10 @@ const ColumnSettingsModal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-lg w-full max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600 flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Column Settings
|
||||
@@ -1800,14 +2016,14 @@ const ColumnSettingsModal = ({
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-2">
|
||||
Drag to reorder columns or toggle visibility
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* Scrollable content */}
|
||||
<div className="px-6 py-4 flex-1 overflow-y-auto">
|
||||
<div className="space-y-1">
|
||||
{columnConfig.map((column, index) => (
|
||||
<button
|
||||
key={column.id}
|
||||
@@ -1824,22 +2040,22 @@ const ColumnSettingsModal = ({
|
||||
// Focus handling for keyboard users
|
||||
}
|
||||
}}
|
||||
className={`flex items-center justify-between p-3 border rounded-lg cursor-move w-full text-left ${
|
||||
className={`flex items-center justify-between p-2.5 border rounded-lg cursor-move w-full text-left transition-colors ${
|
||||
draggedIndex === index
|
||||
? "opacity-50"
|
||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
} border-secondary-200 dark:border-secondary-600`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<GripVertical className="h-4 w-4 text-secondary-400 dark:text-secondary-500" />
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<GripVertical className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||
{column.label}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleVisibility(column.id)}
|
||||
className={`p-1 rounded ${
|
||||
className={`p-1 rounded transition-colors flex-shrink-0 ${
|
||||
column.visible
|
||||
? "text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
: "text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
||||
@@ -1854,8 +2070,11 @@ const ColumnSettingsModal = ({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-secondary-200 dark:border-secondary-600 flex-shrink-0">
|
||||
<div className="flex justify-between">
|
||||
<button type="button" onClick={onReset} className="btn-outline">
|
||||
Reset to Default
|
||||
</button>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -153,6 +153,14 @@ const Packages = () => {
|
||||
}));
|
||||
}, [packagesResponse]);
|
||||
|
||||
// Fetch dashboard stats for card counts (consistent with homepage)
|
||||
const { data: dashboardStats } = useQuery({
|
||||
queryKey: ["dashboardStats"],
|
||||
queryFn: () => dashboardAPI.getStats().then((res) => res.data),
|
||||
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
|
||||
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||
});
|
||||
|
||||
// Fetch hosts data to get total packages count
|
||||
const { data: hosts } = useQuery({
|
||||
queryKey: ["hosts"],
|
||||
@@ -446,25 +454,21 @@ const Packages = () => {
|
||||
const uniquePackageHostsCount = uniquePackageHosts.size;
|
||||
|
||||
// Calculate total packages installed
|
||||
// When filtering by host, count each package once (since it can only be installed once per host)
|
||||
// When not filtering, sum up all installations across all hosts
|
||||
const totalPackagesCount =
|
||||
hostFilter && hostFilter !== "all"
|
||||
? packages?.length || 0
|
||||
: packages?.reduce(
|
||||
(sum, pkg) => sum + (pkg.stats?.totalInstalls || 0),
|
||||
0,
|
||||
) || 0;
|
||||
// Show unique package count (same as table) for consistency
|
||||
const totalPackagesCount = packages?.length || 0;
|
||||
|
||||
// Calculate outdated packages
|
||||
const outdatedPackagesCount =
|
||||
packages?.filter((pkg) => (pkg.stats?.updatesNeeded || 0) > 0).length || 0;
|
||||
|
||||
// Calculate security updates
|
||||
const securityUpdatesCount =
|
||||
packages?.filter((pkg) => (pkg.stats?.securityUpdates || 0) > 0).length ||
|
||||
// Calculate total installations across all hosts
|
||||
const totalInstallationsCount =
|
||||
packages?.reduce((sum, pkg) => sum + (pkg.stats?.totalInstalls || 0), 0) ||
|
||||
0;
|
||||
|
||||
// Use dashboard stats for outdated packages count (consistent with homepage)
|
||||
const outdatedPackagesCount =
|
||||
dashboardStats?.cards?.totalOutdatedPackages || 0;
|
||||
|
||||
// Use dashboard stats for security updates count (consistent with homepage)
|
||||
const securityUpdatesCount = dashboardStats?.cards?.securityUpdates || 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -529,13 +533,13 @@ const Packages = () => {
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6 flex-shrink-0">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-5 gap-4 mb-6 flex-shrink-0">
|
||||
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
||||
<div className="flex items-center">
|
||||
<Package className="h-5 w-5 text-primary-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Total Installed
|
||||
Total Packages
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{totalPackagesCount}
|
||||
@@ -544,6 +548,20 @@ const Packages = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
||||
<div className="flex items-center">
|
||||
<Package className="h-5 w-5 text-blue-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Total Installations
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{totalInstallationsCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
||||
<div className="flex items-center">
|
||||
<Package className="h-5 w-5 text-warning-600 mr-2" />
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useEffect, useId, useState } from "react";
|
||||
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useTheme } from "../contexts/ThemeContext";
|
||||
import { tfaAPI } from "../utils/api";
|
||||
import { isCorsError, tfaAPI } from "../utils/api";
|
||||
|
||||
const Profile = () => {
|
||||
const usernameId = useId();
|
||||
@@ -88,8 +88,15 @@ const Profile = () => {
|
||||
text: result.error || "Failed to update profile",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Network error occurred" });
|
||||
} catch (error) {
|
||||
if (isCorsError(error)) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: "CORS_ORIGIN mismatch - please set your URL in your environment variable",
|
||||
});
|
||||
} else {
|
||||
setMessage({ type: "error", text: "Network error occurred" });
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -133,8 +140,15 @@ const Profile = () => {
|
||||
text: result.error || "Failed to change password",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Network error occurred" });
|
||||
} catch (error) {
|
||||
if (isCorsError(error)) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: "CORS_ORIGIN mismatch - please set your URL in your environment variable",
|
||||
});
|
||||
} else {
|
||||
setMessage({ type: "error", text: "Network error occurred" });
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ const Settings = () => {
|
||||
});
|
||||
|
||||
// Helper function to get curl flags based on settings
|
||||
const getCurlFlags = () => {
|
||||
const _getCurlFlags = () => {
|
||||
return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
|
||||
};
|
||||
|
||||
@@ -144,7 +144,7 @@ const Settings = () => {
|
||||
defaultUserRole: settings.default_user_role || "user",
|
||||
githubRepoUrl:
|
||||
settings.github_repo_url ||
|
||||
"git@github.com:9technologygroup/patchmon.net.git",
|
||||
"https://github.com/PatchMon/PatchMon.git",
|
||||
repositoryType: settings.repository_type || "public",
|
||||
sshKeyPath: settings.ssh_key_path || "",
|
||||
useCustomSshKey: !!settings.ssh_key_path,
|
||||
@@ -1155,28 +1155,39 @@ const Settings = () => {
|
||||
Agent Uninstall Command
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||
<p className="mb-2">
|
||||
<p className="mb-3">
|
||||
To completely remove PatchMon from a host:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
|
||||
curl {getCurlFlags()} {window.location.origin}
|
||||
/api/v1/hosts/remove | sudo bash
|
||||
|
||||
{/* Go Agent Uninstall */}
|
||||
<div className="mb-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
|
||||
sudo patchmon-agent uninstall
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
"sudo patchmon-agent uninstall",
|
||||
);
|
||||
}}
|
||||
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 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>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const command = `curl ${getCurlFlags()} ${window.location.origin}/api/v1/hosts/remove | sudo bash`;
|
||||
navigator.clipboard.writeText(command);
|
||||
// You could add a toast notification here
|
||||
}}
|
||||
className="px-2 py-1 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs">
|
||||
⚠️ This will remove all PatchMon files,
|
||||
⚠️ This command will remove all PatchMon files,
|
||||
configuration, and crontab entries
|
||||
</p>
|
||||
</div>
|
||||
|
||||
399
frontend/src/pages/settings/SettingsMetrics.jsx
Normal file
399
frontend/src/pages/settings/SettingsMetrics.jsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
CheckCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Globe,
|
||||
Info,
|
||||
RefreshCw,
|
||||
Send,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import SettingsLayout from "../../components/SettingsLayout";
|
||||
|
||||
// API functions - will be added to utils/api.js
|
||||
const metricsAPI = {
|
||||
getSettings: () =>
|
||||
fetch("/api/v1/metrics", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
}).then((res) => res.json()),
|
||||
updateSettings: (data) =>
|
||||
fetch("/api/v1/metrics", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
}).then((res) => res.json()),
|
||||
regenerateId: () =>
|
||||
fetch("/api/v1/metrics/regenerate-id", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
}).then((res) => res.json()),
|
||||
sendNow: () =>
|
||||
fetch("/api/v1/metrics/send-now", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
}).then((res) => res.json()),
|
||||
};
|
||||
|
||||
const SettingsMetrics = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [showFullId, setShowFullId] = useState(false);
|
||||
|
||||
// Fetch metrics settings
|
||||
const {
|
||||
data: metricsSettings,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["metrics-settings"],
|
||||
queryFn: () => metricsAPI.getSettings(),
|
||||
});
|
||||
|
||||
// Toggle metrics mutation
|
||||
const toggleMetricsMutation = useMutation({
|
||||
mutationFn: (enabled) =>
|
||||
metricsAPI.updateSettings({ metrics_enabled: enabled }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["metrics-settings"]);
|
||||
},
|
||||
});
|
||||
|
||||
// Regenerate ID mutation
|
||||
const regenerateIdMutation = useMutation({
|
||||
mutationFn: () => metricsAPI.regenerateId(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["metrics-settings"]);
|
||||
},
|
||||
});
|
||||
|
||||
// Send now mutation
|
||||
const sendNowMutation = useMutation({
|
||||
mutationFn: () => metricsAPI.sendNow(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["metrics-settings"]);
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Error loading metrics settings
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
|
||||
{error.message || "Failed to load settings"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const maskId = (id) => {
|
||||
if (!id) return "";
|
||||
if (showFullId) return id;
|
||||
return `${id.substring(0, 8)}...${id.substring(id.length - 8)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center mb-6">
|
||||
<BarChart3 className="h-6 w-6 text-primary-600 mr-3" />
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
Anonymous Metrics & Telemetry
|
||||
</h2>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Help us understand PatchMon's global usage (100% anonymous)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Privacy Information */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg p-6">
|
||||
<div className="flex">
|
||||
<Shield className="h-6 w-6 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||
<div className="ml-4 flex-1">
|
||||
<h3 className="text-base font-semibold text-blue-900 dark:text-blue-100 mb-3">
|
||||
Your Privacy Matters
|
||||
</h3>
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200 space-y-2">
|
||||
<p className="flex items-start">
|
||||
<CheckCircle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
<strong>We do NOT collect:</strong> IP addresses, hostnames,
|
||||
system details, or any personally identifiable information
|
||||
</span>
|
||||
</p>
|
||||
<p className="flex items-start">
|
||||
<CheckCircle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
<strong>We ONLY collect:</strong> An anonymous UUID (for
|
||||
deduplication) and the number of hosts you're monitoring
|
||||
</span>
|
||||
</p>
|
||||
<p className="flex items-start">
|
||||
<CheckCircle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
<strong>Purpose:</strong> Display a live counter on our
|
||||
website showing global PatchMon adoption
|
||||
</span>
|
||||
</p>
|
||||
<p className="flex items-start">
|
||||
<Globe className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
<strong>Open Source:</strong> All code is public and
|
||||
auditable on GitHub
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Toggle */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
|
||||
Enable Anonymous Metrics
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Share anonymous usage statistics to help us showcase PatchMon's
|
||||
global adoption. Data is sent automatically every 24 hours.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toggleMetricsMutation.mutate(!metricsSettings?.metrics_enabled)
|
||||
}
|
||||
disabled={toggleMetricsMutation.isPending}
|
||||
className={`ml-4 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
|
||||
metricsSettings?.metrics_enabled
|
||||
? "bg-primary-600"
|
||||
: "bg-secondary-200 dark:bg-secondary-700"
|
||||
} ${toggleMetricsMutation.isPending ? "opacity-50" : ""}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
metricsSettings?.metrics_enabled
|
||||
? "translate-x-5"
|
||||
: "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="mt-4 pt-4 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div className="flex items-center text-sm">
|
||||
{metricsSettings?.metrics_enabled ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
|
||||
<span className="text-green-700 dark:text-green-400">
|
||||
Metrics enabled - Thank you for supporting PatchMon!
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeOff className="h-4 w-4 text-secondary-500 mr-2" />
|
||||
<span className="text-secondary-600 dark:text-secondary-400">
|
||||
Metrics disabled - No data is being sent
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anonymous ID Section */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
|
||||
Your Anonymous Instance ID
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
This UUID identifies your instance without revealing any
|
||||
personal information
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 bg-secondary-50 dark:bg-secondary-700 rounded-md p-3 font-mono text-sm break-all">
|
||||
{maskId(metricsSettings?.metrics_anonymous_id)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFullId(!showFullId)}
|
||||
className="p-2 text-secondary-600 dark:text-secondary-400 hover:text-secondary-900 dark:hover:text-white"
|
||||
title={showFullId ? "Hide ID" : "Show full ID"}
|
||||
>
|
||||
{showFullId ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => regenerateIdMutation.mutate()}
|
||||
disabled={regenerateIdMutation.isPending}
|
||||
className="inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
||||
>
|
||||
{regenerateIdMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-secondary-700 dark:border-secondary-200 mr-2"></div>
|
||||
Regenerating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Regenerate ID
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendNowMutation.mutate()}
|
||||
disabled={
|
||||
!metricsSettings?.metrics_enabled || sendNowMutation.isPending
|
||||
}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{sendNowMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Send Metrics Now
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{metricsSettings?.metrics_last_sent && (
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Last sent:{" "}
|
||||
{new Date(metricsSettings.metrics_last_sent).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Success/Error Messages */}
|
||||
{regenerateIdMutation.isSuccess && (
|
||||
<div className="mt-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<CheckCircle className="h-4 w-4 text-green-400 dark:text-green-300 mt-0.5" />
|
||||
<p className="ml-2 text-sm text-green-700 dark:text-green-300">
|
||||
Anonymous ID regenerated successfully
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sendNowMutation.isSuccess && (
|
||||
<div className="mt-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<CheckCircle className="h-4 w-4 text-green-400 dark:text-green-300 mt-0.5" />
|
||||
<div className="ml-2 text-sm text-green-700 dark:text-green-300">
|
||||
<p className="font-medium">Metrics sent successfully!</p>
|
||||
{sendNowMutation.data?.data && (
|
||||
<p className="mt-1">
|
||||
Sent: {sendNowMutation.data.data.hostCount} hosts, version{" "}
|
||||
{sendNowMutation.data.data.version}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sendNowMutation.isError && (
|
||||
<div className="mt-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-4 w-4 text-red-400 dark:text-red-300 mt-0.5" />
|
||||
<div className="ml-2 text-sm text-red-700 dark:text-red-300">
|
||||
{sendNowMutation.error?.message || "Failed to send metrics"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Information Panel */}
|
||||
<div className="bg-secondary-50 dark:bg-secondary-800/50 border border-secondary-200 dark:border-secondary-700 rounded-lg p-6">
|
||||
<div className="flex">
|
||||
<Info className="h-5 w-5 text-secondary-500 dark:text-secondary-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="ml-3 text-sm text-secondary-700 dark:text-secondary-300">
|
||||
<h4 className="font-medium mb-2">How it works:</h4>
|
||||
<ul className="space-y-1 list-disc list-inside">
|
||||
<li>
|
||||
Metrics are sent automatically every 24 hours when enabled
|
||||
</li>
|
||||
<li>
|
||||
Only host count and version number are transmitted (no
|
||||
sensitive data)
|
||||
</li>
|
||||
<li>The anonymous UUID prevents duplicate counting</li>
|
||||
<li>You can regenerate your ID or opt-out at any time</li>
|
||||
<li>
|
||||
All collected data is displayed publicly on{" "}
|
||||
<a
|
||||
href="https://patchmon.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 dark:text-primary-400 hover:underline"
|
||||
>
|
||||
patchmon.net
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsMetrics;
|
||||
@@ -5,7 +5,7 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || "/api/v1";
|
||||
// Create axios instance with default config
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
timeout: 10000, // 10 seconds
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
@@ -56,11 +56,23 @@ export const dashboardAPI = {
|
||||
const url = `/dashboard/hosts/${hostId}${queryString ? `?${queryString}` : ""}`;
|
||||
return api.get(url);
|
||||
},
|
||||
getHostQueue: (hostId, params = {}) => {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const url = `/dashboard/hosts/${hostId}/queue${queryString ? `?${queryString}` : ""}`;
|
||||
return api.get(url);
|
||||
},
|
||||
getHostWsStatus: (hostId) => api.get(`/dashboard/hosts/${hostId}/ws-status`),
|
||||
getWsStatusByApiId: (apiId) => api.get(`/ws/status/${apiId}`),
|
||||
getPackageTrends: (params = {}) => {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const url = `/dashboard/package-trends${queryString ? `?${queryString}` : ""}`;
|
||||
return api.get(url);
|
||||
},
|
||||
getPackageSpikeAnalysis: (params = {}) => {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const url = `/dashboard/package-spike-analysis${queryString ? `?${queryString}` : ""}`;
|
||||
return api.get(url);
|
||||
},
|
||||
getRecentUsers: () => api.get("/dashboard/recent-users"),
|
||||
getRecentCollection: () => api.get("/dashboard/recent-collection"),
|
||||
};
|
||||
@@ -75,8 +87,12 @@ export const adminHostsAPI = {
|
||||
api.post(`/hosts/${hostId}/regenerate-credentials`),
|
||||
updateGroup: (hostId, hostGroupId) =>
|
||||
api.put(`/hosts/${hostId}/group`, { hostGroupId }),
|
||||
updateGroups: (hostId, groupIds) =>
|
||||
api.put(`/hosts/${hostId}/groups`, { groupIds }),
|
||||
bulkUpdateGroup: (hostIds, hostGroupId) =>
|
||||
api.put("/hosts/bulk/group", { hostIds, hostGroupId }),
|
||||
bulkUpdateGroups: (hostIds, groupIds) =>
|
||||
api.put("/hosts/bulk/groups", { hostIds, groupIds }),
|
||||
toggleAutoUpdate: (hostId, autoUpdate) =>
|
||||
api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }),
|
||||
updateFriendlyName: (hostId, friendlyName) =>
|
||||
@@ -205,7 +221,82 @@ export const packagesAPI = {
|
||||
};
|
||||
|
||||
// Utility functions
|
||||
export const isCorsError = (error) => {
|
||||
// Check for browser-level CORS errors (when request is blocked before reaching server)
|
||||
if (error.message?.includes("Failed to fetch") && !error.response) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for TypeError with Failed to fetch (common CORS error pattern)
|
||||
if (
|
||||
error.name === "TypeError" &&
|
||||
error.message?.includes("Failed to fetch")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for backend CORS errors that get converted to 500 by proxy
|
||||
if (error.response?.status === 500) {
|
||||
// Check if the error message contains CORS-related text
|
||||
if (
|
||||
error.message?.includes("Not allowed by CORS") ||
|
||||
error.message?.includes("CORS") ||
|
||||
error.message?.includes("cors")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the response data contains CORS error information
|
||||
if (
|
||||
error.response?.data?.error?.includes("CORS") ||
|
||||
error.response?.data?.error?.includes("cors") ||
|
||||
error.response?.data?.message?.includes("CORS") ||
|
||||
error.response?.data?.message?.includes("cors") ||
|
||||
error.response?.data?.message?.includes("Not allowed by CORS")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for specific CORS error patterns from backend logs
|
||||
if (
|
||||
error.message?.includes("origin") &&
|
||||
error.message?.includes("callback")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if this is likely a CORS error based on context
|
||||
// If we're accessing from localhost but CORS_ORIGIN is set to fabio, this is likely CORS
|
||||
const currentOrigin = window.location.origin;
|
||||
if (
|
||||
currentOrigin === "http://localhost:3000" &&
|
||||
error.config?.url?.includes("/api/")
|
||||
) {
|
||||
// This is likely a CORS error when accessing from localhost
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for CORS-related errors
|
||||
return (
|
||||
error.message?.includes("CORS") ||
|
||||
error.message?.includes("cors") ||
|
||||
error.message?.includes("Access to fetch") ||
|
||||
error.message?.includes("blocked by CORS policy") ||
|
||||
error.message?.includes("Cross-Origin Request Blocked") ||
|
||||
error.message?.includes("NetworkError when attempting to fetch resource") ||
|
||||
error.message?.includes("ERR_BLOCKED_BY_CLIENT") ||
|
||||
error.message?.includes("ERR_NETWORK") ||
|
||||
error.message?.includes("ERR_CONNECTION_REFUSED")
|
||||
);
|
||||
};
|
||||
|
||||
export const formatError = (error) => {
|
||||
// Check for CORS-related errors
|
||||
if (isCorsError(error)) {
|
||||
return "CORS_ORIGIN mismatch - please set your URL in your environment variable";
|
||||
}
|
||||
|
||||
if (error.response?.data?.message) {
|
||||
return error.response.data.message;
|
||||
}
|
||||
|
||||
@@ -1,43 +1,104 @@
|
||||
import { Monitor, Server } from "lucide-react";
|
||||
import { DiWindows } from "react-icons/di";
|
||||
// Import OS icons from react-icons
|
||||
// Import OS icons from react-icons Simple Icons - using only confirmed available icons
|
||||
import {
|
||||
SiAlmalinux,
|
||||
SiAlpinelinux,
|
||||
SiArchlinux,
|
||||
SiCentos,
|
||||
SiDebian,
|
||||
SiDeepin,
|
||||
SiElementary,
|
||||
SiFedora,
|
||||
SiGentoo,
|
||||
SiKalilinux,
|
||||
SiLinux,
|
||||
SiLinuxmint,
|
||||
SiMacos,
|
||||
SiManjaro,
|
||||
SiOpensuse,
|
||||
SiOracle,
|
||||
SiParrotsecurity,
|
||||
SiPopos,
|
||||
SiRedhat,
|
||||
SiRockylinux,
|
||||
SiSlackware,
|
||||
SiSolus,
|
||||
SiSuse,
|
||||
SiTails,
|
||||
SiUbuntu,
|
||||
SiZorin,
|
||||
} from "react-icons/si";
|
||||
|
||||
/**
|
||||
* OS Icon mapping utility
|
||||
* Maps operating system types to appropriate react-icons components
|
||||
* Now uses specific icons based on actual OS names from /etc/os-release
|
||||
*/
|
||||
export const getOSIcon = (osType) => {
|
||||
if (!osType) return Monitor;
|
||||
|
||||
const os = osType.toLowerCase();
|
||||
|
||||
// Linux distributions with authentic react-icons
|
||||
if (os.includes("ubuntu")) return SiUbuntu;
|
||||
// Ubuntu and Ubuntu variants
|
||||
if (os.includes("ubuntu")) {
|
||||
// For Ubuntu variants, use generic Ubuntu icon as fallback
|
||||
return SiUbuntu;
|
||||
}
|
||||
|
||||
// Pop!_OS
|
||||
if (os.includes("pop") || os.includes("pop!_os")) return SiPopos;
|
||||
|
||||
// Linux Mint
|
||||
if (os.includes("mint") || os.includes("linuxmint")) return SiLinuxmint;
|
||||
|
||||
// Elementary OS
|
||||
if (os.includes("elementary")) return SiElementary;
|
||||
|
||||
// Debian
|
||||
if (os.includes("debian")) return SiDebian;
|
||||
if (
|
||||
os.includes("centos") ||
|
||||
os.includes("rhel") ||
|
||||
os.includes("red hat") ||
|
||||
os.includes("almalinux") ||
|
||||
os.includes("rocky")
|
||||
)
|
||||
return SiCentos;
|
||||
if (os === "ol" || os.includes("oraclelinux") || os.includes("oracle linux"))
|
||||
return SiLinux; // Use generic Linux icon for Oracle Linux
|
||||
|
||||
// Rocky Linux
|
||||
if (os.includes("rocky")) return SiRockylinux;
|
||||
|
||||
// AlmaLinux
|
||||
if (os.includes("alma") || os.includes("almalinux")) return SiAlmalinux;
|
||||
|
||||
// CentOS
|
||||
if (os.includes("centos")) return SiCentos;
|
||||
|
||||
// Red Hat Enterprise Linux
|
||||
if (os.includes("rhel") || os.includes("red hat")) return SiRedhat;
|
||||
|
||||
// Fedora
|
||||
if (os.includes("fedora")) return SiFedora;
|
||||
|
||||
// Oracle Linux
|
||||
if (os === "ol" || os.includes("oraclelinux") || os.includes("oracle linux"))
|
||||
return SiOracle;
|
||||
|
||||
// SUSE distributions
|
||||
if (os.includes("opensuse")) return SiOpensuse;
|
||||
if (os.includes("suse")) return SiSuse;
|
||||
|
||||
// Arch-based distributions
|
||||
if (os.includes("arch")) return SiArchlinux;
|
||||
if (os.includes("manjaro")) return SiManjaro;
|
||||
if (os.includes("endeavour") || os.includes("endeavouros"))
|
||||
return SiArchlinux; // Fallback to Arch
|
||||
if (os.includes("garuda")) return SiArchlinux; // Fallback to Arch
|
||||
if (os.includes("blackarch")) return SiArchlinux; // Fallback to Arch
|
||||
|
||||
// Other distributions
|
||||
if (os.includes("alpine")) return SiAlpinelinux;
|
||||
if (os.includes("suse") || os.includes("opensuse")) return SiLinux; // SUSE uses generic Linux icon
|
||||
if (os.includes("gentoo")) return SiGentoo;
|
||||
if (os.includes("slackware")) return SiSlackware;
|
||||
if (os.includes("zorin")) return SiZorin;
|
||||
if (os.includes("deepin")) return SiDeepin;
|
||||
if (os.includes("solus")) return SiSolus;
|
||||
if (os.includes("tails")) return SiTails;
|
||||
if (os.includes("parrot")) return SiParrotsecurity;
|
||||
if (os.includes("kali")) return SiKalilinux;
|
||||
|
||||
// Generic Linux
|
||||
if (os.includes("linux")) return SiLinux;
|
||||
@@ -70,27 +131,83 @@ export const getOSColor = (osType) => {
|
||||
/**
|
||||
* OS Display name utility
|
||||
* Provides clean, formatted OS names for display
|
||||
* Updated to handle more distributions from /etc/os-release
|
||||
*/
|
||||
export const getOSDisplayName = (osType) => {
|
||||
if (!osType) return "Unknown";
|
||||
|
||||
const os = osType.toLowerCase();
|
||||
|
||||
// Linux distributions
|
||||
if (os.includes("ubuntu")) return "Ubuntu";
|
||||
// Ubuntu and variants
|
||||
if (os.includes("ubuntu")) {
|
||||
if (os.includes("kubuntu")) return "Kubuntu";
|
||||
if (os.includes("lubuntu")) return "Lubuntu";
|
||||
if (os.includes("xubuntu")) return "Xubuntu";
|
||||
if (os.includes("ubuntu mate") || os.includes("ubuntumate"))
|
||||
return "Ubuntu MATE";
|
||||
if (os.includes("ubuntu budgie") || os.includes("ubuntubudgie"))
|
||||
return "Ubuntu Budgie";
|
||||
if (os.includes("ubuntu studio") || os.includes("ubuntustudio"))
|
||||
return "Ubuntu Studio";
|
||||
if (os.includes("ubuntu kylin") || os.includes("ubuntukylin"))
|
||||
return "Ubuntu Kylin";
|
||||
return "Ubuntu";
|
||||
}
|
||||
|
||||
// Pop!_OS
|
||||
if (os.includes("pop") || os.includes("pop!_os")) return "Pop!_OS";
|
||||
|
||||
// Linux Mint
|
||||
if (os.includes("mint") || os.includes("linuxmint")) return "Linux Mint";
|
||||
|
||||
// Elementary OS
|
||||
if (os.includes("elementary")) return "Elementary OS";
|
||||
|
||||
// Debian
|
||||
if (os.includes("debian")) return "Debian";
|
||||
if (os.includes("centos")) return "CentOS";
|
||||
if (os.includes("almalinux")) return "AlmaLinux";
|
||||
|
||||
// Rocky Linux
|
||||
if (os.includes("rocky")) return "Rocky Linux";
|
||||
if (os === "ol" || os.includes("oraclelinux") || os.includes("oracle linux"))
|
||||
return "Oracle Linux";
|
||||
|
||||
// AlmaLinux
|
||||
if (os.includes("alma") || os.includes("almalinux")) return "AlmaLinux";
|
||||
|
||||
// CentOS
|
||||
if (os.includes("centos")) return "CentOS";
|
||||
|
||||
// Red Hat Enterprise Linux
|
||||
if (os.includes("rhel") || os.includes("red hat"))
|
||||
return "Red Hat Enterprise Linux";
|
||||
|
||||
// Fedora
|
||||
if (os.includes("fedora")) return "Fedora";
|
||||
if (os.includes("arch")) return "Arch Linux";
|
||||
if (os.includes("suse")) return "SUSE Linux";
|
||||
|
||||
// Oracle Linux
|
||||
if (os === "ol" || os.includes("oraclelinux") || os.includes("oracle linux"))
|
||||
return "Oracle Linux";
|
||||
|
||||
// SUSE distributions
|
||||
if (os.includes("opensuse")) return "openSUSE";
|
||||
if (os.includes("suse")) return "SUSE Linux";
|
||||
|
||||
// Arch-based distributions
|
||||
if (os.includes("arch")) return "Arch Linux";
|
||||
if (os.includes("manjaro")) return "Manjaro";
|
||||
if (os.includes("endeavour") || os.includes("endeavouros"))
|
||||
return "EndeavourOS";
|
||||
if (os.includes("garuda")) return "Garuda Linux";
|
||||
if (os.includes("blackarch")) return "BlackArch Linux";
|
||||
|
||||
// Other distributions
|
||||
if (os.includes("alpine")) return "Alpine Linux";
|
||||
if (os.includes("gentoo")) return "Gentoo";
|
||||
if (os.includes("slackware")) return "Slackware";
|
||||
if (os.includes("zorin")) return "Zorin OS";
|
||||
if (os.includes("deepin")) return "Deepin";
|
||||
if (os.includes("solus")) return "Solus";
|
||||
if (os.includes("tails")) return "Tails";
|
||||
if (os.includes("parrot")) return "Parrot Security";
|
||||
if (os.includes("kali")) return "Kali Linux";
|
||||
|
||||
// Generic Linux
|
||||
if (os.includes("linux")) return "Linux";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Agent as HttpAgent } from "node:http";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
@@ -14,6 +15,15 @@ export default defineConfig({
|
||||
target: `http://${process.env.BACKEND_HOST || "localhost"}:${process.env.BACKEND_PORT || "3001"}`,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
// Configure HTTP agent to support more concurrent connections
|
||||
// Fixes 1000ms timeout issue when using HTTP (not HTTPS) with multiple hosts
|
||||
agent: new HttpAgent({
|
||||
keepAlive: true,
|
||||
maxSockets: 50, // Increase from default 6 to handle multiple hosts
|
||||
maxFreeSockets: 10,
|
||||
timeout: 60000,
|
||||
keepAliveMsecs: 1000,
|
||||
}),
|
||||
configure:
|
||||
process.env.VITE_ENABLE_LOGGING === "true"
|
||||
? (proxy, _options) => {
|
||||
@@ -37,6 +47,11 @@ export default defineConfig({
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
"/admin": {
|
||||
target: `http://${process.env.BACKEND_HOST || "localhost"}:${process.env.BACKEND_PORT || "3001"}`,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
2261
package-lock.json
generated
2261
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "patchmon",
|
||||
"version": "1.2.9",
|
||||
"version": "1.3.1",
|
||||
"description": "Linux Patch Monitoring System",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint:fix": "biome check --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.4",
|
||||
"@biomejs/biome": "^2.3.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"lefthook": "^1.13.4"
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user