mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-10-22 23:32:03 +00:00
Compare commits
8 Commits
e6200f4f0e
...
651eda3e4c
Author | SHA1 | Date | |
---|---|---|---|
|
651eda3e4c | ||
|
c328123bd3 | ||
|
46eb797ac3 | ||
|
c43afeb127 | ||
|
5b77a1328d | ||
|
9a40d5e6ee | ||
|
fdd0cfd619 | ||
|
de236f9ae2 |
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
|
||||
|
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-arm64
Executable file
BIN
agents/patchmon-agent-linux-arm64
Executable file
Binary file not shown.
@@ -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,117 @@ 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"
|
||||
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" \
|
||||
-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 +453,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 +530,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 +552,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!"
|
||||
|
@@ -3,6 +3,12 @@ DATABASE_URL="postgresql://patchmon_user:p@tchm0n_p@55@localhost:5432/patchmon_d
|
||||
PM_DB_CONN_MAX_ATTEMPTS=30
|
||||
PM_DB_CONN_WAIT_INTERVAL=2
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=your-redis-password-here
|
||||
REDIS_DB=0
|
||||
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
|
@@ -14,11 +14,12 @@
|
||||
"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",
|
||||
"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 +32,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";
|
@@ -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])
|
||||
@@ -324,3 +337,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,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,32 @@ 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",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 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 +262,19 @@ 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.AGENT_COMMANDS, 1),
|
||||
]);
|
||||
|
||||
// Calculate overview metrics
|
||||
@@ -241,23 +282,20 @@ 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,
|
||||
|
||||
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,
|
||||
|
||||
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,
|
||||
|
||||
totalAutomations: Object.values(stats).reduce((sum, queueStats) => {
|
||||
return (
|
||||
@@ -305,10 +343,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 +356,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 +372,23 @@ 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: "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[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.AGENT_COMMANDS],
|
||||
},
|
||||
].sort((a, b) => {
|
||||
// Sort by last run timestamp (most recent first)
|
||||
|
@@ -8,6 +8,7 @@ const {
|
||||
requireViewPackages,
|
||||
requireViewUsers,
|
||||
} = require("../middleware/permissions");
|
||||
const { queueManager } = require("../services/automation");
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
@@ -200,11 +201,16 @@ router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => {
|
||||
agent_version: true,
|
||||
auto_update: true,
|
||||
notes: true,
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
api_id: true,
|
||||
host_group_memberships: {
|
||||
include: {
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
@@ -354,11 +360,15 @@ router.get(
|
||||
prisma.hosts.findUnique({
|
||||
where: { id: hostId },
|
||||
include: {
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
host_group_memberships: {
|
||||
include: {
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
host_packages: {
|
||||
@@ -413,6 +423,51 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// Get agent queue status for a specific host
|
||||
router.get(
|
||||
"/hosts/:hostId/queue",
|
||||
authenticateToken,
|
||||
requireViewHosts,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { hostId } = req.params;
|
||||
const { limit = 20 } = req.query;
|
||||
|
||||
// Get the host to find its API ID
|
||||
const host = await prisma.hosts.findUnique({
|
||||
where: { id: hostId },
|
||||
select: { api_id: true, friendly_name: true },
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
// Get queue jobs for this host
|
||||
const queueData = await queueManager.getHostJobs(
|
||||
host.api_id,
|
||||
parseInt(limit, 10),
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
hostId,
|
||||
apiId: host.api_id,
|
||||
friendlyName: host.friendly_name,
|
||||
...queueData,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching host queue status:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to fetch host queue status",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get recent users ordered by last_login desc
|
||||
router.get(
|
||||
"/recent-users",
|
||||
@@ -511,22 +566,34 @@ router.get(
|
||||
packages_count: true,
|
||||
security_count: true,
|
||||
total_packages: true,
|
||||
host_id: true,
|
||||
status: true,
|
||||
},
|
||||
orderBy: {
|
||||
timestamp: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
// Process data to show actual values (no averaging)
|
||||
// Enhanced data validation and processing
|
||||
const processedData = trendsData
|
||||
.filter((record) => record.total_packages !== null) // Only include records with valid data
|
||||
.filter((record) => {
|
||||
// Enhanced validation
|
||||
return (
|
||||
record.total_packages !== null &&
|
||||
record.total_packages >= 0 &&
|
||||
record.packages_count >= 0 &&
|
||||
record.security_count >= 0 &&
|
||||
record.security_count <= record.packages_count && // Security can't exceed outdated
|
||||
record.status === "success"
|
||||
); // Only include successful reports
|
||||
})
|
||||
.map((record) => {
|
||||
const date = new Date(record.timestamp);
|
||||
let timeKey;
|
||||
|
||||
if (daysInt <= 1) {
|
||||
// For hourly view, use exact timestamp
|
||||
timeKey = date.toISOString().substring(0, 16); // YYYY-MM-DDTHH:MM
|
||||
// For hourly view, group by hour only (not minutes)
|
||||
timeKey = date.toISOString().substring(0, 13); // YYYY-MM-DDTHH
|
||||
} else {
|
||||
// For daily view, group by day
|
||||
timeKey = date.toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
@@ -537,64 +604,342 @@ router.get(
|
||||
total_packages: record.total_packages,
|
||||
packages_count: record.packages_count || 0,
|
||||
security_count: record.security_count || 0,
|
||||
host_id: record.host_id,
|
||||
timestamp: record.timestamp,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.timeKey.localeCompare(b.timeKey)); // Sort by time
|
||||
});
|
||||
|
||||
// Get hosts list for dropdown (always fetch for dropdown functionality)
|
||||
// Determine if we need aggregation based on host filter
|
||||
const needsAggregation =
|
||||
!hostId || hostId === "all" || hostId === "undefined";
|
||||
|
||||
let aggregatedArray;
|
||||
|
||||
if (needsAggregation) {
|
||||
// For "All Hosts" mode, we need to calculate the actual total packages differently
|
||||
// Instead of aggregating historical data (which is per-host), we'll use the current total
|
||||
// and show that as a flat line, since total packages don't change much over time
|
||||
|
||||
// Get the current total packages count (unique packages across all hosts)
|
||||
const currentTotalPackages = await prisma.packages.count({
|
||||
where: {
|
||||
host_packages: {
|
||||
some: {}, // At least one host has this package
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Aggregate data by timeKey when looking at "All Hosts" or no specific host
|
||||
const aggregatedData = processedData.reduce((acc, item) => {
|
||||
if (!acc[item.timeKey]) {
|
||||
acc[item.timeKey] = {
|
||||
timeKey: item.timeKey,
|
||||
total_packages: currentTotalPackages, // Use current total packages
|
||||
packages_count: 0,
|
||||
security_count: 0,
|
||||
record_count: 0,
|
||||
host_ids: new Set(),
|
||||
min_timestamp: item.timestamp,
|
||||
max_timestamp: item.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// For outdated and security packages: SUM (these represent counts across hosts)
|
||||
acc[item.timeKey].packages_count += item.packages_count;
|
||||
acc[item.timeKey].security_count += item.security_count;
|
||||
|
||||
acc[item.timeKey].record_count += 1;
|
||||
acc[item.timeKey].host_ids.add(item.host_id);
|
||||
|
||||
// Track timestamp range
|
||||
if (item.timestamp < acc[item.timeKey].min_timestamp) {
|
||||
acc[item.timeKey].min_timestamp = item.timestamp;
|
||||
}
|
||||
if (item.timestamp > acc[item.timeKey].max_timestamp) {
|
||||
acc[item.timeKey].max_timestamp = item.timestamp;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Convert to array and add metadata
|
||||
aggregatedArray = Object.values(aggregatedData)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
host_count: item.host_ids.size,
|
||||
host_ids: Array.from(item.host_ids),
|
||||
}))
|
||||
.sort((a, b) => a.timeKey.localeCompare(b.timeKey));
|
||||
} else {
|
||||
// For specific host, show individual data points without aggregation
|
||||
// But still group by timeKey to handle multiple reports from same host in same time period
|
||||
const hostAggregatedData = processedData.reduce((acc, item) => {
|
||||
if (!acc[item.timeKey]) {
|
||||
acc[item.timeKey] = {
|
||||
timeKey: item.timeKey,
|
||||
total_packages: 0,
|
||||
packages_count: 0,
|
||||
security_count: 0,
|
||||
record_count: 0,
|
||||
host_ids: new Set([item.host_id]),
|
||||
min_timestamp: item.timestamp,
|
||||
max_timestamp: item.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// For same host, take the latest values (not sum)
|
||||
// This handles cases where a host reports multiple times in the same time period
|
||||
if (item.timestamp > acc[item.timeKey].max_timestamp) {
|
||||
acc[item.timeKey].total_packages = item.total_packages;
|
||||
acc[item.timeKey].packages_count = item.packages_count;
|
||||
acc[item.timeKey].security_count = item.security_count;
|
||||
acc[item.timeKey].max_timestamp = item.timestamp;
|
||||
}
|
||||
|
||||
acc[item.timeKey].record_count += 1;
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Convert to array
|
||||
aggregatedArray = Object.values(hostAggregatedData)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
host_count: item.host_ids.size,
|
||||
host_ids: Array.from(item.host_ids),
|
||||
}))
|
||||
.sort((a, b) => a.timeKey.localeCompare(b.timeKey));
|
||||
}
|
||||
|
||||
// Handle sparse data by filling missing time periods
|
||||
const fillMissingPeriods = (data, daysInt) => {
|
||||
const filledData = [];
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - daysInt);
|
||||
|
||||
const dataMap = new Map(data.map((item) => [item.timeKey, item]));
|
||||
|
||||
const endDate = new Date();
|
||||
const currentDate = new Date(startDate);
|
||||
|
||||
// Find the last known values for interpolation
|
||||
let lastKnownValues = null;
|
||||
if (data.length > 0) {
|
||||
lastKnownValues = {
|
||||
total_packages: data[0].total_packages,
|
||||
packages_count: data[0].packages_count,
|
||||
security_count: data[0].security_count,
|
||||
};
|
||||
}
|
||||
|
||||
while (currentDate <= endDate) {
|
||||
let timeKey;
|
||||
if (daysInt <= 1) {
|
||||
timeKey = currentDate.toISOString().substring(0, 13); // Hourly
|
||||
currentDate.setHours(currentDate.getHours() + 1);
|
||||
} else {
|
||||
timeKey = currentDate.toISOString().split("T")[0]; // Daily
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
if (dataMap.has(timeKey)) {
|
||||
const item = dataMap.get(timeKey);
|
||||
filledData.push(item);
|
||||
// Update last known values
|
||||
lastKnownValues = {
|
||||
total_packages: item.total_packages,
|
||||
packages_count: item.packages_count,
|
||||
security_count: item.security_count,
|
||||
};
|
||||
} else {
|
||||
// For missing periods, use the last known values (interpolation)
|
||||
// This creates a continuous line instead of gaps
|
||||
filledData.push({
|
||||
timeKey,
|
||||
total_packages: lastKnownValues?.total_packages || 0,
|
||||
packages_count: lastKnownValues?.packages_count || 0,
|
||||
security_count: lastKnownValues?.security_count || 0,
|
||||
record_count: 0,
|
||||
host_count: 0,
|
||||
host_ids: [],
|
||||
min_timestamp: null,
|
||||
max_timestamp: null,
|
||||
isInterpolated: true, // Mark as interpolated for debugging
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return filledData;
|
||||
};
|
||||
|
||||
const finalProcessedData = fillMissingPeriods(aggregatedArray, daysInt);
|
||||
|
||||
// Get hosts list for dropdown
|
||||
const hostsList = await prisma.hosts.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
friendly_name: true,
|
||||
hostname: true,
|
||||
last_update: true,
|
||||
status: true,
|
||||
},
|
||||
orderBy: {
|
||||
friendly_name: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
// Get current package state for offline fallback
|
||||
let currentPackageState = null;
|
||||
if (hostId && hostId !== "all" && hostId !== "undefined") {
|
||||
// Get current package counts for specific host
|
||||
const currentState = await prisma.host_packages.aggregate({
|
||||
where: {
|
||||
host_id: hostId,
|
||||
},
|
||||
_count: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Get counts for boolean fields separately
|
||||
const outdatedCount = await prisma.host_packages.count({
|
||||
where: {
|
||||
host_id: hostId,
|
||||
needs_update: true,
|
||||
},
|
||||
});
|
||||
|
||||
const securityCount = await prisma.host_packages.count({
|
||||
where: {
|
||||
host_id: hostId,
|
||||
is_security_update: true,
|
||||
},
|
||||
});
|
||||
|
||||
currentPackageState = {
|
||||
total_packages: currentState._count.id,
|
||||
packages_count: outdatedCount,
|
||||
security_count: securityCount,
|
||||
};
|
||||
} else {
|
||||
// Get current package counts for all hosts
|
||||
// Total packages = count of unique packages installed on at least one host
|
||||
const totalPackagesCount = await prisma.packages.count({
|
||||
where: {
|
||||
host_packages: {
|
||||
some: {}, // At least one host has this package
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Get counts for boolean fields separately
|
||||
const outdatedCount = await prisma.host_packages.count({
|
||||
where: {
|
||||
needs_update: true,
|
||||
},
|
||||
});
|
||||
|
||||
const securityCount = await prisma.host_packages.count({
|
||||
where: {
|
||||
is_security_update: true,
|
||||
},
|
||||
});
|
||||
|
||||
currentPackageState = {
|
||||
total_packages: totalPackagesCount,
|
||||
packages_count: outdatedCount,
|
||||
security_count: securityCount,
|
||||
};
|
||||
}
|
||||
|
||||
// Format data for chart
|
||||
const chartData = {
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: "Total Packages",
|
||||
label: needsAggregation
|
||||
? "Total Packages (All Hosts)"
|
||||
: "Total Packages",
|
||||
data: [],
|
||||
borderColor: "#3B82F6", // Blue
|
||||
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
||||
tension: 0.4,
|
||||
hidden: true, // Hidden by default
|
||||
spanGaps: true, // Connect lines across missing data
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
},
|
||||
{
|
||||
label: "Outdated Packages",
|
||||
label: needsAggregation
|
||||
? "Total Outdated Packages"
|
||||
: "Outdated Packages",
|
||||
data: [],
|
||||
borderColor: "#F59E0B", // Orange
|
||||
backgroundColor: "rgba(245, 158, 11, 0.1)",
|
||||
tension: 0.4,
|
||||
spanGaps: true, // Connect lines across missing data
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
},
|
||||
{
|
||||
label: "Security Packages",
|
||||
label: needsAggregation
|
||||
? "Total Security Packages"
|
||||
: "Security Packages",
|
||||
data: [],
|
||||
borderColor: "#EF4444", // Red
|
||||
backgroundColor: "rgba(239, 68, 68, 0.1)",
|
||||
tension: 0.4,
|
||||
spanGaps: true, // Connect lines across missing data
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Process aggregated data
|
||||
processedData.forEach((item) => {
|
||||
finalProcessedData.forEach((item) => {
|
||||
chartData.labels.push(item.timeKey);
|
||||
chartData.datasets[0].data.push(item.total_packages);
|
||||
chartData.datasets[1].data.push(item.packages_count);
|
||||
chartData.datasets[2].data.push(item.security_count);
|
||||
});
|
||||
|
||||
// Calculate data quality metrics
|
||||
const dataQuality = {
|
||||
totalRecords: trendsData.length,
|
||||
validRecords: processedData.length,
|
||||
aggregatedPoints: aggregatedArray.length,
|
||||
filledPoints: finalProcessedData.length,
|
||||
recordsWithNullTotal: trendsData.filter(
|
||||
(r) => r.total_packages === null,
|
||||
).length,
|
||||
recordsWithInvalidData: trendsData.length - processedData.length,
|
||||
successfulReports: trendsData.filter((r) => r.status === "success")
|
||||
.length,
|
||||
failedReports: trendsData.filter((r) => r.status === "error").length,
|
||||
};
|
||||
|
||||
res.json({
|
||||
chartData,
|
||||
hosts: hostsList,
|
||||
period: daysInt,
|
||||
hostId: hostId || "all",
|
||||
currentPackageState,
|
||||
dataQuality,
|
||||
aggregationInfo: {
|
||||
hasData: aggregatedArray.length > 0,
|
||||
hasGaps: finalProcessedData.some((item) => item.record_count === 0),
|
||||
lastDataPoint:
|
||||
aggregatedArray.length > 0
|
||||
? aggregatedArray[aggregatedArray.length - 1]
|
||||
: null,
|
||||
aggregationMode: needsAggregation
|
||||
? "sum_across_hosts"
|
||||
: "individual_host_data",
|
||||
explanation: needsAggregation
|
||||
? "Data is summed across all hosts for each time period"
|
||||
: "Data shows individual host values without cross-host aggregation",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching package trends:", error);
|
||||
@@ -603,4 +948,348 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// Diagnostic endpoint to investigate package spikes
|
||||
router.get(
|
||||
"/package-spike-analysis",
|
||||
authenticateToken,
|
||||
requireViewHosts,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { date, time, hours = 2 } = req.query;
|
||||
|
||||
if (!date || !time) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Date and time parameters are required. Format: date=2025-10-17&time=18:00",
|
||||
});
|
||||
}
|
||||
|
||||
// Parse the specific date and time
|
||||
const targetDateTime = new Date(`${date}T${time}:00`);
|
||||
const startTime = new Date(targetDateTime);
|
||||
startTime.setHours(startTime.getHours() - parseInt(hours, 10));
|
||||
const endTime = new Date(targetDateTime);
|
||||
endTime.setHours(endTime.getHours() + parseInt(hours, 10));
|
||||
|
||||
console.log(
|
||||
`Analyzing package spike around ${targetDateTime.toISOString()}`,
|
||||
);
|
||||
console.log(
|
||||
`Time range: ${startTime.toISOString()} to ${endTime.toISOString()}`,
|
||||
);
|
||||
|
||||
// Get all update history records in the time window
|
||||
const spikeData = await prisma.update_history.findMany({
|
||||
where: {
|
||||
timestamp: {
|
||||
gte: startTime,
|
||||
lte: endTime,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
host_id: true,
|
||||
timestamp: true,
|
||||
packages_count: true,
|
||||
security_count: true,
|
||||
total_packages: true,
|
||||
status: true,
|
||||
error_message: true,
|
||||
execution_time: true,
|
||||
payload_size_kb: true,
|
||||
hosts: {
|
||||
select: {
|
||||
friendly_name: true,
|
||||
hostname: true,
|
||||
os_type: true,
|
||||
os_version: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
timestamp: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
// Analyze the data
|
||||
const analysis = {
|
||||
timeWindow: {
|
||||
start: startTime.toISOString(),
|
||||
end: endTime.toISOString(),
|
||||
target: targetDateTime.toISOString(),
|
||||
},
|
||||
totalRecords: spikeData.length,
|
||||
successfulReports: spikeData.filter((r) => r.status === "success")
|
||||
.length,
|
||||
failedReports: spikeData.filter((r) => r.status === "error").length,
|
||||
uniqueHosts: [...new Set(spikeData.map((r) => r.host_id))].length,
|
||||
hosts: {},
|
||||
timeline: [],
|
||||
summary: {
|
||||
maxPackagesCount: 0,
|
||||
maxSecurityCount: 0,
|
||||
maxTotalPackages: 0,
|
||||
avgPackagesCount: 0,
|
||||
avgSecurityCount: 0,
|
||||
avgTotalPackages: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// Group by host and analyze each host's behavior
|
||||
spikeData.forEach((record) => {
|
||||
const hostId = record.host_id;
|
||||
if (!analysis.hosts[hostId]) {
|
||||
analysis.hosts[hostId] = {
|
||||
hostInfo: record.hosts,
|
||||
records: [],
|
||||
summary: {
|
||||
totalReports: 0,
|
||||
successfulReports: 0,
|
||||
failedReports: 0,
|
||||
maxPackagesCount: 0,
|
||||
maxSecurityCount: 0,
|
||||
maxTotalPackages: 0,
|
||||
avgPackagesCount: 0,
|
||||
avgSecurityCount: 0,
|
||||
avgTotalPackages: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
analysis.hosts[hostId].records.push({
|
||||
timestamp: record.timestamp,
|
||||
packages_count: record.packages_count,
|
||||
security_count: record.security_count,
|
||||
total_packages: record.total_packages,
|
||||
status: record.status,
|
||||
error_message: record.error_message,
|
||||
execution_time: record.execution_time,
|
||||
payload_size_kb: record.payload_size_kb,
|
||||
});
|
||||
|
||||
analysis.hosts[hostId].summary.totalReports++;
|
||||
if (record.status === "success") {
|
||||
analysis.hosts[hostId].summary.successfulReports++;
|
||||
analysis.hosts[hostId].summary.maxPackagesCount = Math.max(
|
||||
analysis.hosts[hostId].summary.maxPackagesCount,
|
||||
record.packages_count,
|
||||
);
|
||||
analysis.hosts[hostId].summary.maxSecurityCount = Math.max(
|
||||
analysis.hosts[hostId].summary.maxSecurityCount,
|
||||
record.security_count,
|
||||
);
|
||||
analysis.hosts[hostId].summary.maxTotalPackages = Math.max(
|
||||
analysis.hosts[hostId].summary.maxTotalPackages,
|
||||
record.total_packages || 0,
|
||||
);
|
||||
} else {
|
||||
analysis.hosts[hostId].summary.failedReports++;
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate averages for each host
|
||||
Object.keys(analysis.hosts).forEach((hostId) => {
|
||||
const host = analysis.hosts[hostId];
|
||||
const successfulRecords = host.records.filter(
|
||||
(r) => r.status === "success",
|
||||
);
|
||||
|
||||
if (successfulRecords.length > 0) {
|
||||
host.summary.avgPackagesCount = Math.round(
|
||||
successfulRecords.reduce((sum, r) => sum + r.packages_count, 0) /
|
||||
successfulRecords.length,
|
||||
);
|
||||
host.summary.avgSecurityCount = Math.round(
|
||||
successfulRecords.reduce((sum, r) => sum + r.security_count, 0) /
|
||||
successfulRecords.length,
|
||||
);
|
||||
host.summary.avgTotalPackages = Math.round(
|
||||
successfulRecords.reduce(
|
||||
(sum, r) => sum + (r.total_packages || 0),
|
||||
0,
|
||||
) / successfulRecords.length,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Create timeline with hourly/daily aggregation
|
||||
const timelineMap = new Map();
|
||||
spikeData.forEach((record) => {
|
||||
const timeKey = record.timestamp.toISOString().substring(0, 13); // Hourly
|
||||
if (!timelineMap.has(timeKey)) {
|
||||
timelineMap.set(timeKey, {
|
||||
timestamp: timeKey,
|
||||
totalReports: 0,
|
||||
successfulReports: 0,
|
||||
failedReports: 0,
|
||||
totalPackagesCount: 0,
|
||||
totalSecurityCount: 0,
|
||||
totalTotalPackages: 0,
|
||||
uniqueHosts: new Set(),
|
||||
});
|
||||
}
|
||||
|
||||
const timelineEntry = timelineMap.get(timeKey);
|
||||
timelineEntry.totalReports++;
|
||||
timelineEntry.uniqueHosts.add(record.host_id);
|
||||
|
||||
if (record.status === "success") {
|
||||
timelineEntry.successfulReports++;
|
||||
timelineEntry.totalPackagesCount += record.packages_count;
|
||||
timelineEntry.totalSecurityCount += record.security_count;
|
||||
timelineEntry.totalTotalPackages += record.total_packages || 0;
|
||||
} else {
|
||||
timelineEntry.failedReports++;
|
||||
}
|
||||
});
|
||||
|
||||
// Convert timeline map to array
|
||||
analysis.timeline = Array.from(timelineMap.values())
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
uniqueHosts: entry.uniqueHosts.size,
|
||||
}))
|
||||
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||
|
||||
// Calculate overall summary
|
||||
const successfulRecords = spikeData.filter((r) => r.status === "success");
|
||||
if (successfulRecords.length > 0) {
|
||||
analysis.summary.maxPackagesCount = Math.max(
|
||||
...successfulRecords.map((r) => r.packages_count),
|
||||
);
|
||||
analysis.summary.maxSecurityCount = Math.max(
|
||||
...successfulRecords.map((r) => r.security_count),
|
||||
);
|
||||
analysis.summary.maxTotalPackages = Math.max(
|
||||
...successfulRecords.map((r) => r.total_packages || 0),
|
||||
);
|
||||
analysis.summary.avgPackagesCount = Math.round(
|
||||
successfulRecords.reduce((sum, r) => sum + r.packages_count, 0) /
|
||||
successfulRecords.length,
|
||||
);
|
||||
analysis.summary.avgSecurityCount = Math.round(
|
||||
successfulRecords.reduce((sum, r) => sum + r.security_count, 0) /
|
||||
successfulRecords.length,
|
||||
);
|
||||
analysis.summary.avgTotalPackages = Math.round(
|
||||
successfulRecords.reduce(
|
||||
(sum, r) => sum + (r.total_packages || 0),
|
||||
0,
|
||||
) / successfulRecords.length,
|
||||
);
|
||||
}
|
||||
|
||||
// Identify potential causes of the spike
|
||||
const potentialCauses = [];
|
||||
|
||||
// Check for hosts with unusually high package counts
|
||||
Object.keys(analysis.hosts).forEach((hostId) => {
|
||||
const host = analysis.hosts[hostId];
|
||||
if (
|
||||
host.summary.maxPackagesCount >
|
||||
analysis.summary.avgPackagesCount * 2
|
||||
) {
|
||||
potentialCauses.push({
|
||||
type: "high_package_count",
|
||||
hostId,
|
||||
hostName: host.hostInfo.friendly_name || host.hostInfo.hostname,
|
||||
value: host.summary.maxPackagesCount,
|
||||
avg: analysis.summary.avgPackagesCount,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check for multiple hosts reporting at the same time (this explains the 500 vs 59 discrepancy)
|
||||
const concurrentReports = analysis.timeline.filter(
|
||||
(entry) => entry.uniqueHosts > 1,
|
||||
);
|
||||
if (concurrentReports.length > 0) {
|
||||
potentialCauses.push({
|
||||
type: "concurrent_reports",
|
||||
description:
|
||||
"Multiple hosts reported simultaneously - this explains why chart shows higher numbers than individual host reports",
|
||||
count: concurrentReports.length,
|
||||
details: concurrentReports.map((entry) => ({
|
||||
timestamp: entry.timestamp,
|
||||
totalPackagesCount: entry.totalPackagesCount,
|
||||
uniqueHosts: entry.uniqueHosts,
|
||||
avgPerHost: Math.round(
|
||||
entry.totalPackagesCount / entry.uniqueHosts,
|
||||
),
|
||||
})),
|
||||
explanation:
|
||||
"The chart sums package counts across all hosts. If multiple hosts report at the same time, the chart shows the total sum, not individual host counts.",
|
||||
});
|
||||
}
|
||||
|
||||
// Check for failed reports that might indicate system issues
|
||||
if (analysis.failedReports > 0) {
|
||||
potentialCauses.push({
|
||||
type: "failed_reports",
|
||||
count: analysis.failedReports,
|
||||
percentage: Math.round(
|
||||
(analysis.failedReports / analysis.totalRecords) * 100,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Add aggregation explanation
|
||||
const aggregationExplanation = {
|
||||
type: "aggregation_explanation",
|
||||
description: "Chart Aggregation Logic",
|
||||
details: {
|
||||
howItWorks:
|
||||
"The package trends chart sums package counts across all hosts for each time period",
|
||||
individualHosts:
|
||||
"Each host reports its own package count (e.g., 59 packages)",
|
||||
chartDisplay:
|
||||
"Chart shows the sum of all hosts' package counts (e.g., 59 + other hosts = 500)",
|
||||
timeGrouping:
|
||||
"Multiple hosts reporting in the same hour/day are aggregated together",
|
||||
},
|
||||
example: {
|
||||
host1: "Host A reports 59 outdated packages",
|
||||
host2: "Host B reports 120 outdated packages",
|
||||
host3: "Host C reports 321 outdated packages",
|
||||
chartShows: "Chart displays 500 total packages (59+120+321)",
|
||||
},
|
||||
};
|
||||
potentialCauses.push(aggregationExplanation);
|
||||
|
||||
// Add specific host breakdown if a host ID is provided
|
||||
let specificHostAnalysis = null;
|
||||
if (req.query.hostId) {
|
||||
const hostId = req.query.hostId;
|
||||
const hostData = analysis.hosts[hostId];
|
||||
if (hostData) {
|
||||
specificHostAnalysis = {
|
||||
hostId,
|
||||
hostInfo: hostData.hostInfo,
|
||||
summary: hostData.summary,
|
||||
records: hostData.records,
|
||||
explanation: `This host reported ${hostData.summary.maxPackagesCount} outdated packages, but the chart shows ${analysis.summary.maxPackagesCount} because it sums across all hosts that reported at the same time.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
analysis,
|
||||
potentialCauses,
|
||||
specificHostAnalysis,
|
||||
recommendations: [
|
||||
"Check if any hosts had major package updates around this time",
|
||||
"Verify if any new hosts were added to the system",
|
||||
"Check for system maintenance or updates that might have triggered package checks",
|
||||
"Review any automation or scheduled tasks that run around 6pm",
|
||||
"Check if any repositories were updated or new packages were released",
|
||||
"Remember: Chart shows SUM of all hosts' package counts, not individual host counts",
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error analyzing package spike:", error);
|
||||
res.status(500).json({ error: "Failed to analyze package spike" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
@@ -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,
|
||||
|
@@ -14,7 +14,7 @@ const {
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Secure endpoint to download the agent script (requires API authentication)
|
||||
// Secure endpoint to download the agent binary (requires API authentication)
|
||||
router.get("/agent/download", async (req, res) => {
|
||||
try {
|
||||
// Verify API credentials
|
||||
@@ -34,46 +34,50 @@ router.get("/agent/download", async (req, res) => {
|
||||
return res.status(401).json({ error: "Invalid API credentials" });
|
||||
}
|
||||
|
||||
// Serve agent script directly from file system
|
||||
// Get architecture parameter (default to amd64)
|
||||
const architecture = req.query.arch || "amd64";
|
||||
|
||||
// Validate architecture
|
||||
const validArchitectures = ["amd64", "386", "arm64"];
|
||||
if (!validArchitectures.includes(architecture)) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid architecture. Must be one of: amd64, 386, arm64",
|
||||
});
|
||||
}
|
||||
|
||||
// Serve agent binary directly from file system
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const agentPath = path.join(__dirname, "../../../agents/patchmon-agent.sh");
|
||||
const binaryName = `patchmon-agent-linux-${architecture}`;
|
||||
const binaryPath = path.join(__dirname, "../../../agents", binaryName);
|
||||
|
||||
if (!fs.existsSync(agentPath)) {
|
||||
return res.status(404).json({ error: "Agent script not found" });
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
return res.status(404).json({
|
||||
error: `Agent binary not found for architecture: ${architecture}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 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";
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Inject the curl flags into the script
|
||||
scriptContent = scriptContent.replace(
|
||||
'CURL_FLAGS=""',
|
||||
`CURL_FLAGS="${curlFlags}"`,
|
||||
);
|
||||
|
||||
res.setHeader("Content-Type", "application/x-shellscript");
|
||||
// Set appropriate headers for binary download
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
'attachment; filename="patchmon-agent.sh"',
|
||||
`attachment; filename="${binaryName}"`,
|
||||
);
|
||||
res.send(scriptContent);
|
||||
|
||||
// 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 binary" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -158,7 +162,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 +178,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 +208,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,7 +244,10 @@ 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.",
|
||||
});
|
||||
@@ -406,7 +437,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({
|
||||
@@ -732,18 +763,96 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Admin endpoint to bulk update host groups
|
||||
// TODO: Admin endpoint to bulk update host groups - needs to be rewritten for many-to-many relationship
|
||||
// router.put(
|
||||
// "/bulk/group",
|
||||
// authenticateToken,
|
||||
// requireManageHosts,
|
||||
// [
|
||||
// body("hostIds").isArray().withMessage("Host IDs must be an array"),
|
||||
// body("hostIds.*")
|
||||
// .isLength({ min: 1 })
|
||||
// .withMessage("Each host ID must be provided"),
|
||||
// body("hostGroupId").optional(),
|
||||
// ],
|
||||
// async (req, res) => {
|
||||
// try {
|
||||
// const errors = validationResult(req);
|
||||
// if (!errors.isEmpty()) {
|
||||
// return res.status(400).json({ errors: errors.array() });
|
||||
// }
|
||||
|
||||
// const { hostIds, hostGroupId } = req.body;
|
||||
|
||||
// // If hostGroupId is provided, verify the group exists
|
||||
// if (hostGroupId) {
|
||||
// const hostGroup = await prisma.host_groups.findUnique({
|
||||
// where: { id: hostGroupId },
|
||||
// });
|
||||
|
||||
// if (!hostGroup) {
|
||||
// return res.status(400).json({ error: "Host group not found" });
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Check if all hosts exist
|
||||
// const existingHosts = await prisma.hosts.findMany({
|
||||
// where: { id: { in: hostIds } },
|
||||
// select: { id: true, friendly_name: true },
|
||||
// });
|
||||
|
||||
// if (existingHosts.length !== hostIds.length) {
|
||||
// const foundIds = existingHosts.map((h) => h.id);
|
||||
// const missingIds = hostIds.filter((id) => !foundIds.includes(id));
|
||||
// return res.status(400).json({
|
||||
// error: "Some hosts not found",
|
||||
// missingHostIds: missingIds,
|
||||
// });
|
||||
// }
|
||||
|
||||
// // Bulk update host groups
|
||||
// const updateResult = await prisma.hosts.updateMany({
|
||||
// where: { id: { in: hostIds } },
|
||||
// data: {
|
||||
// host_group_id: hostGroupId || null,
|
||||
// updated_at: new Date(),
|
||||
// },
|
||||
// });
|
||||
|
||||
// // 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,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
|
||||
// res.json({
|
||||
// message: `Successfully updated ${updateResult.count} host${updateResult.count !== 1 ? "s" : ""}`,
|
||||
// updatedCount: updateResult.count,
|
||||
// hosts: updatedHosts,
|
||||
// });
|
||||
// } catch (error) {
|
||||
// console.error("Bulk host group update error:", error);
|
||||
// res.status(500).json({ error: "Failed to update host groups" });
|
||||
// }
|
||||
// },
|
||||
// );
|
||||
|
||||
// Admin endpoint to update host groups (many-to-many)
|
||||
router.put(
|
||||
"/bulk/group",
|
||||
"/:hostId/groups",
|
||||
authenticateToken,
|
||||
requireManageHosts,
|
||||
[
|
||||
body("hostIds").isArray().withMessage("Host IDs must be an array"),
|
||||
body("hostIds.*")
|
||||
.isLength({ min: 1 })
|
||||
.withMessage("Each host ID must be provided"),
|
||||
body("hostGroupId").optional(),
|
||||
],
|
||||
[body("groupIds").isArray().optional()],
|
||||
async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -751,72 +860,83 @@ router.put(
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { hostIds, hostGroupId } = req.body;
|
||||
const { hostId } = req.params;
|
||||
const { groupIds = [] } = req.body;
|
||||
|
||||
// If hostGroupId is provided, verify the group exists
|
||||
if (hostGroupId) {
|
||||
const hostGroup = await prisma.host_groups.findUnique({
|
||||
where: { id: hostGroupId },
|
||||
// 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 (!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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if all hosts exist
|
||||
const existingHosts = await prisma.hosts.findMany({
|
||||
where: { id: { in: hostIds } },
|
||||
select: { id: true, friendly_name: true },
|
||||
});
|
||||
|
||||
if (existingHosts.length !== hostIds.length) {
|
||||
const foundIds = existingHosts.map((h) => h.id);
|
||||
const missingIds = hostIds.filter((id) => !foundIds.includes(id));
|
||||
return res.status(400).json({
|
||||
error: "Some hosts not found",
|
||||
missingHostIds: missingIds,
|
||||
// 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 },
|
||||
});
|
||||
}
|
||||
|
||||
// Bulk update host groups
|
||||
const updateResult = await prisma.hosts.updateMany({
|
||||
where: { id: { in: hostIds } },
|
||||
data: {
|
||||
host_group_id: hostGroupId || null,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
});
|
||||
// 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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
// 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: `Successfully updated ${updateResult.count} host${updateResult.count !== 1 ? "s" : ""}`,
|
||||
updatedCount: updateResult.count,
|
||||
hosts: updatedHosts,
|
||||
message: "Host groups updated successfully",
|
||||
host: updatedHost,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Bulk host group update error:", error);
|
||||
console.error("Host groups update error:", error);
|
||||
res.status(500).json({ error: "Failed to update host groups" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Admin endpoint to update host group
|
||||
// Legacy endpoint to update single host group (for backward compatibility)
|
||||
router.put(
|
||||
"/:hostId/group",
|
||||
authenticateToken,
|
||||
@@ -832,6 +952,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 +964,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 +975,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 +1045,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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1175,13 +1320,17 @@ router.get("/install", async (req, res) => {
|
||||
// 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, 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 FORCE_INSTALL="${forceInstall ? "true" : "false"}"
|
||||
export ARCHITECTURE="${architecture}"
|
||||
|
||||
`;
|
||||
|
||||
@@ -1558,16 +1707,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 +1780,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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@@ -8,102 +8,9 @@ const { getSettings, updateSettings } = require("../services/settingsService");
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 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) {
|
||||
@@ -290,15 +197,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({
|
||||
|
143
backend/src/routes/wsRoutes.js
Normal file
143
backend/src/routes/wsRoutes.js
Normal file
@@ -0,0 +1,143 @@
|
||||
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 by api_id (no database access - pure memory lookup)
|
||||
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) {
|
||||
console.error("[SSE] Session validation failed:", validation.reason);
|
||||
console.error("[SSE] Invalid session for api_id:", apiId);
|
||||
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) {
|
||||
console.error("[SSE] JWT verification failed:", err.message);
|
||||
console.error("[SSE] Invalid token for api_id:", apiId);
|
||||
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,6 +39,7 @@ 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,
|
||||
waitForDatabase,
|
||||
@@ -65,10 +66,13 @@ 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 wsRoutes = require("./routes/wsRoutes");
|
||||
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();
|
||||
@@ -255,6 +259,9 @@ 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");
|
||||
|
||||
// Trust proxy (needed when behind reverse proxy) and remove X-Powered-By
|
||||
if (process.env.TRUST_PROXY) {
|
||||
@@ -342,12 +349,17 @@ app.use(
|
||||
// Allow non-browser/SSR tools with no origin
|
||||
if (!origin) return callback(null, true);
|
||||
if (allowedOrigins.includes(origin)) 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);
|
||||
return callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
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(
|
||||
@@ -429,6 +441,123 @@ app.use(
|
||||
app.use(`/api/${apiVersion}/gethomepage`, gethomepageRoutes);
|
||||
app.use(`/api/${apiVersion}/automation`, automationRoutes);
|
||||
app.use(`/api/${apiVersion}/docker`, dockerRoutes);
|
||||
app.use(`/api/${apiVersion}/ws`, wsRoutes);
|
||||
|
||||
// Bull Board - will be populated after queue manager initializes
|
||||
let bullBoardRouter = null;
|
||||
const bullBoardSessions = new Map(); // Store authenticated sessions
|
||||
|
||||
// Mount Bull Board at /admin instead of /api/v1/admin to avoid path conflicts
|
||||
app.use(`/admin/queues`, (_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");
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Authentication middleware for Bull Board
|
||||
app.use(`/admin/queues`, async (req, res, next) => {
|
||||
// Skip authentication for static assets only
|
||||
if (req.path.includes("/static/") || req.path.includes("/favicon")) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// 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 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
|
||||
res.cookie("bull-board-session", newSessionId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 3600000, // 1 hour
|
||||
});
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.use(`/admin/queues`, (req, res, next) => {
|
||||
if (bullBoardRouter) {
|
||||
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("/admin/queues", (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) => {
|
||||
@@ -451,10 +580,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 +589,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 +864,34 @@ 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("/admin/queues");
|
||||
|
||||
// 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 /admin/queues (secured)");
|
||||
|
||||
// Initialize WS layer with the underlying HTTP server
|
||||
initAgentWs(server, prisma);
|
||||
|
||||
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);
|
||||
|
190
backend/src/services/agentWs.js
Normal file
190
backend/src/services/agentWs.js
Normal file
@@ -0,0 +1,190 @@
|
||||
// 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 || !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", () => {
|
||||
// Currently we don't need to handle agent->server messages
|
||||
});
|
||||
|
||||
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 }),
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
broadcastSettingsUpdate,
|
||||
pushReportNow,
|
||||
pushSettingsUpdate,
|
||||
// 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,
|
||||
};
|
@@ -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,7 +14,7 @@ class GitHubUpdateCheck {
|
||||
/**
|
||||
* Process GitHub update check job
|
||||
*/
|
||||
async process(job) {
|
||||
async process(_job) {
|
||||
const startTime = Date.now();
|
||||
console.log("🔍 Starting GitHub update check...");
|
||||
|
||||
|
@@ -1,20 +1,21 @@
|
||||
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");
|
||||
|
||||
// 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",
|
||||
AGENT_COMMANDS: "agent-commands",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -60,7 +61,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 +89,8 @@ 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);
|
||||
|
||||
console.log("✅ All automation classes initialized");
|
||||
}
|
||||
@@ -133,11 +135,11 @@ class QueueManager {
|
||||
},
|
||||
);
|
||||
|
||||
// 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,
|
||||
@@ -145,6 +147,153 @@ class QueueManager {
|
||||
},
|
||||
);
|
||||
|
||||
// Agent Commands Worker
|
||||
this.workers[QUEUE_NAMES.AGENT_COMMANDS] = new Worker(
|
||||
QUEUE_NAMES.AGENT_COMMANDS,
|
||||
async (job) => {
|
||||
const { api_id, type, update_interval } = job.data || {};
|
||||
console.log("[agent-commands] processing job", job.id, api_id, type);
|
||||
|
||||
// Log job attempt to history - use job.id as the unique identifier
|
||||
const attemptNumber = job.attemptsMade || 1;
|
||||
const historyId = job.id; // Single row per job, updated with each attempt
|
||||
|
||||
try {
|
||||
if (!api_id || !type) {
|
||||
throw new Error("invalid job data");
|
||||
}
|
||||
|
||||
// Find host by api_id
|
||||
const host = await prisma.hosts.findUnique({
|
||||
where: { api_id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Ensure agent is connected; if not, retry later
|
||||
if (!agentWs.isConnected(api_id)) {
|
||||
const error = new Error("agent not connected");
|
||||
// Log failed attempt
|
||||
await prisma.job_history.upsert({
|
||||
where: { id: historyId },
|
||||
create: {
|
||||
id: historyId,
|
||||
job_id: job.id,
|
||||
queue_name: QUEUE_NAMES.AGENT_COMMANDS,
|
||||
job_name: type,
|
||||
host_id: host?.id,
|
||||
api_id,
|
||||
status: "failed",
|
||||
attempt_number: attemptNumber,
|
||||
error_message: error.message,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
update: {
|
||||
status: "failed",
|
||||
attempt_number: attemptNumber,
|
||||
error_message: error.message,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
"[agent-commands] agent not connected, will retry",
|
||||
api_id,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Process the command
|
||||
let result;
|
||||
if (type === "settings_update") {
|
||||
agentWs.pushSettingsUpdate(api_id, update_interval);
|
||||
console.log(
|
||||
"[agent-commands] delivered settings_update",
|
||||
api_id,
|
||||
update_interval,
|
||||
);
|
||||
result = { delivered: true, update_interval };
|
||||
} else if (type === "report_now") {
|
||||
agentWs.pushReportNow(api_id);
|
||||
console.log("[agent-commands] delivered report_now", api_id);
|
||||
result = { delivered: true };
|
||||
} else {
|
||||
throw new Error("unsupported agent command");
|
||||
}
|
||||
|
||||
// Log successful completion
|
||||
await prisma.job_history.upsert({
|
||||
where: { id: historyId },
|
||||
create: {
|
||||
id: historyId,
|
||||
job_id: job.id,
|
||||
queue_name: QUEUE_NAMES.AGENT_COMMANDS,
|
||||
job_name: type,
|
||||
host_id: host?.id,
|
||||
api_id,
|
||||
status: "completed",
|
||||
attempt_number: attemptNumber,
|
||||
output: result,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
},
|
||||
update: {
|
||||
status: "completed",
|
||||
attempt_number: attemptNumber,
|
||||
output: result,
|
||||
error_message: null,
|
||||
updated_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Log error to history (if not already logged above)
|
||||
if (error.message !== "agent not connected") {
|
||||
const host = await prisma.hosts
|
||||
.findUnique({
|
||||
where: { api_id },
|
||||
select: { id: true },
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
await prisma.job_history
|
||||
.upsert({
|
||||
where: { id: historyId },
|
||||
create: {
|
||||
id: historyId,
|
||||
job_id: job.id,
|
||||
queue_name: QUEUE_NAMES.AGENT_COMMANDS,
|
||||
job_name: type || "unknown",
|
||||
host_id: host?.id,
|
||||
api_id,
|
||||
status: "failed",
|
||||
attempt_number: attemptNumber,
|
||||
error_message: error.message,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
update: {
|
||||
status: "failed",
|
||||
attempt_number: attemptNumber,
|
||||
error_message: error.message,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
})
|
||||
.catch((err) =>
|
||||
console.error("[agent-commands] failed to log error:", err),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
connection: redisConnection,
|
||||
concurrency: 10,
|
||||
},
|
||||
);
|
||||
|
||||
// Add error handling for all workers
|
||||
Object.values(this.workers).forEach((worker) => {
|
||||
worker.on("error", (error) => {
|
||||
@@ -184,7 +333,7 @@ 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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,8 +351,10 @@ 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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -262,6 +413,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 +487,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();
|
||||
|
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...");
|
||||
|
||||
|
@@ -3,9 +3,9 @@ const IORedis = require("ioredis");
|
||||
// Redis connection configuration
|
||||
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,
|
||||
db: parseInt(process.env.REDIS_DB, 10) || 0,
|
||||
retryDelayOnFailover: 100,
|
||||
maxRetriesPerRequest: null, // BullMQ requires this to be 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;
|
@@ -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 where it says:
|
||||
```yaml
|
||||
environment:
|
||||
REDIS_PASSWORD: # CREATE A STRONG REDIS PASSWORD AND PUT IT HERE
|
||||
```
|
||||
5. Update the corresponding `REDIS_PASSWORD` in the backend service where it says:
|
||||
```yaml
|
||||
environment:
|
||||
REDIS_PASSWORD: REPLACE_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,12 @@ 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!** |
|
||||
|
||||
#### Backend Service
|
||||
|
||||
##### Database Configuration
|
||||
@@ -116,6 +133,15 @@ 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` |
|
||||
|
||||
##### 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 +191,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 +228,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 +282,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 +306,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 +317,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
|
||||
|
@@ -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,11 @@ services:
|
||||
SERVER_HOST: localhost
|
||||
SERVER_PORT: 3000
|
||||
CORS_ORIGIN: http://localhost:3000
|
||||
# Redis Configuration
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
REDIS_PASSWORD: 1NS3CU6E_DEV_R3DIS_PASSW0RD
|
||||
REDIS_DB: 0
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
@@ -41,6 +62,8 @@ services:
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
develop:
|
||||
watch:
|
||||
- action: sync
|
||||
|
@@ -16,6 +16,21 @@ services:
|
||||
timeout: 5s
|
||||
retries: 7
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: redis-server /usr/local/etc/redis/redis.conf
|
||||
environment:
|
||||
REDIS_PASSWORD: # CREATE A STRONG REDIS PASSWORD AND PUT IT HERE
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
- ./docker/redis.conf:/usr/local/etc/redis/redis.conf:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 7
|
||||
|
||||
backend:
|
||||
image: ghcr.io/patchmon/patchmon-backend:latest
|
||||
restart: unless-stopped
|
||||
@@ -28,11 +43,18 @@ services:
|
||||
SERVER_HOST: localhost
|
||||
SERVER_PORT: 3000
|
||||
CORS_ORIGIN: http://localhost:3000
|
||||
# Redis Configuration
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
REDIS_PASSWORD: REPLACE_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 +67,5 @@ services:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
agent_files:
|
||||
|
@@ -52,6 +52,64 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# SSE (Server-Sent Events) specific configuration
|
||||
location /api/v1/ws/status/ {
|
||||
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
|
||||
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;
|
||||
|
||||
# Critical SSE settings
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_set_header Connection '';
|
||||
proxy_http_version 1.1;
|
||||
chunked_transfer_encoding off;
|
||||
|
||||
# Timeout settings for long-lived connections
|
||||
proxy_read_timeout 24h;
|
||||
proxy_send_timeout 24h;
|
||||
proxy_connect_timeout 60s;
|
||||
|
||||
# Disable nginx buffering for real-time streaming
|
||||
proxy_request_buffering off;
|
||||
proxy_max_temp_file_size 0;
|
||||
|
||||
# CORS headers for SSE
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization" always;
|
||||
|
||||
# Handle preflight requests
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# WebSocket upgrade handling
|
||||
location /api/v1/agents/ws {
|
||||
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;
|
||||
|
||||
# WebSocket timeout settings
|
||||
proxy_read_timeout 24h;
|
||||
proxy_send_timeout 24h;
|
||||
proxy_connect_timeout 60s;
|
||||
|
||||
# Disable buffering for WebSocket
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
# Static assets caching
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
|
35
docker/redis.conf
Normal file
35
docker/redis.conf
Normal file
@@ -0,0 +1,35 @@
|
||||
# Redis Configuration for PatchMon Production
|
||||
# Security settings
|
||||
requirepass ${REDIS_PASSWORD}
|
||||
rename-command FLUSHDB ""
|
||||
rename-command FLUSHALL ""
|
||||
rename-command DEBUG ""
|
||||
rename-command CONFIG "CONFIG_${REDIS_PASSWORD}"
|
||||
|
||||
# Memory management
|
||||
maxmemory 256mb
|
||||
maxmemory-policy allkeys-lru
|
||||
|
||||
# Persistence settings
|
||||
save 900 1
|
||||
save 300 10
|
||||
save 60 10000
|
||||
|
||||
# Logging
|
||||
loglevel notice
|
||||
logfile ""
|
||||
|
||||
# Network security
|
||||
bind 127.0.0.1
|
||||
protected-mode yes
|
||||
|
||||
# Performance tuning
|
||||
tcp-keepalive 300
|
||||
timeout 0
|
||||
|
||||
# Disable dangerous commands
|
||||
rename-command SHUTDOWN "SHUTDOWN_${REDIS_PASSWORD}"
|
||||
rename-command KEYS ""
|
||||
rename-command MONITOR ""
|
||||
rename-command SLAVEOF ""
|
||||
rename-command REPLICAOF ""
|
@@ -35,7 +35,7 @@
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^7.1.5"
|
||||
},
|
||||
"overrides": {
|
||||
|
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 |
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,7 +26,7 @@ const AgentManagementTab = () => {
|
||||
});
|
||||
|
||||
// Helper function to get curl flags based on settings
|
||||
const getCurlFlags = () => {
|
||||
const _getCurlFlags = () => {
|
||||
return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
|
||||
};
|
||||
|
||||
@@ -177,29 +177,40 @@ const AgentManagementTab = () => {
|
||||
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, configuration, and
|
||||
crontab entries
|
||||
⚠️ This command will remove all PatchMon files,
|
||||
configuration, and crontab entries
|
||||
</p>
|
||||
</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,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,20 @@ 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 === "Every hour") {
|
||||
const now = new Date();
|
||||
const nextHour = new Date(now);
|
||||
@@ -175,6 +187,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 +202,13 @@ 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 === "Every hour") {
|
||||
const now = new Date();
|
||||
const nextHour = new Date(now);
|
||||
@@ -198,6 +218,19 @@ 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 = `/admin/queues?token=${encodeURIComponent(token)}`;
|
||||
window.open(url, "_blank", "width=1200,height=800");
|
||||
};
|
||||
|
||||
const triggerManualJob = async (jobType, data = {}) => {
|
||||
try {
|
||||
let endpoint;
|
||||
@@ -206,13 +239,15 @@ 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 === "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 +338,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 +550,18 @@ 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("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 +570,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,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,7 @@ const HostDetail = () => {
|
||||
const [activeTab, setActiveTab] = useState("host");
|
||||
const [historyPage, setHistoryPage] = useState(0);
|
||||
const [historyLimit] = useState(10);
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const {
|
||||
data: host,
|
||||
@@ -66,6 +72,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 +139,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 +159,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,6 +197,15 @@ 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),
|
||||
@@ -238,49 +326,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 +395,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 +543,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 +621,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>
|
||||
@@ -1097,12 +1235,8 @@ const HostDetail = () => {
|
||||
</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 +1248,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 +1270,9 @@ const HostDetail = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Queue */}
|
||||
{activeTab === "queue" && <AgentQueueTab hostId={hostId} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1168,8 +1305,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 +1328,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 +1450,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 +1529,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 +1542,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 +1560,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 +1568,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 +1586,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 +1594,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 +1612,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 +1620,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 +1633,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 +1667,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 +1698,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 +1924,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,6 +392,118 @@ 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
|
||||
const fetchInitialStatus = async () => {
|
||||
const statusPromises = hosts
|
||||
.filter((host) => host.api_id)
|
||||
.map(async (host) => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/ws/status/${host.api_id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return { apiId: host.api_id, status: data.data };
|
||||
}
|
||||
} catch (_error) {
|
||||
// Silently handle errors
|
||||
}
|
||||
return {
|
||||
apiId: host.api_id,
|
||||
status: { connected: false, secure: false },
|
||||
};
|
||||
});
|
||||
|
||||
const results = await Promise.all(statusPromises);
|
||||
const initialStatusMap = {};
|
||||
results.forEach(({ apiId, status }) => {
|
||||
initialStatusMap[apiId] = status;
|
||||
});
|
||||
|
||||
setWsStatusMap(initialStatusMap);
|
||||
};
|
||||
|
||||
fetchInitialStatus();
|
||||
}, [hosts]);
|
||||
|
||||
// Subscribe to WebSocket status changes for all hosts via SSE
|
||||
useEffect(() => {
|
||||
if (!hosts || hosts.length === 0) return;
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) return;
|
||||
|
||||
const eventSources = new Map();
|
||||
let isMounted = true;
|
||||
|
||||
const connectHost = (apiId) => {
|
||||
if (!isMounted || eventSources.has(apiId)) return;
|
||||
|
||||
try {
|
||||
const es = new EventSource(
|
||||
`/api/v1/ws/status/${apiId}/stream?token=${encodeURIComponent(token)}`,
|
||||
);
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (isMounted) {
|
||||
setWsStatusMap((prev) => {
|
||||
const newMap = { ...prev, [apiId]: data };
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
} catch (_err) {
|
||||
// Silently handle parse errors
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = (_error) => {
|
||||
console.log(`[SSE] Connection error for ${apiId}, retrying...`);
|
||||
es?.close();
|
||||
eventSources.delete(apiId);
|
||||
if (isMounted) {
|
||||
// Retry connection after 5 seconds with exponential backoff
|
||||
setTimeout(() => connectHost(apiId), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
eventSources.set(apiId, es);
|
||||
} catch (_err) {
|
||||
// Silently handle connection errors
|
||||
}
|
||||
};
|
||||
|
||||
// Connect to all hosts
|
||||
for (const host of hosts) {
|
||||
if (host.api_id) {
|
||||
connectHost(host.api_id);
|
||||
} else {
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
isMounted = false;
|
||||
for (const es of eventSources.values()) {
|
||||
es.close();
|
||||
}
|
||||
eventSources.clear();
|
||||
};
|
||||
}, [hosts]);
|
||||
|
||||
const bulkUpdateGroupMutation = useMutation({
|
||||
mutationFn: ({ hostIds, hostGroupId }) =>
|
||||
adminHostsAPI.bulkUpdateGroup(hostIds, hostGroupId),
|
||||
@@ -439,7 +545,7 @@ const Hosts = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const updateHostGroupMutation = useMutation({
|
||||
const _updateHostGroupMutation = useMutation({
|
||||
mutationFn: ({ hostId, hostGroupId }) => {
|
||||
console.log("updateHostGroupMutation called with:", {
|
||||
hostId,
|
||||
@@ -485,6 +591,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
|
||||
@@ -562,7 +708,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 +716,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 +802,7 @@ const Hosts = () => {
|
||||
sortDirection,
|
||||
searchParams,
|
||||
hideStale,
|
||||
wsStatusMap,
|
||||
]);
|
||||
|
||||
// Get unique OS types from hosts for dynamic dropdown
|
||||
@@ -756,10 +904,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 +979,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 +1034,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 +1161,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 +1221,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 +1296,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 +1660,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"
|
||||
@@ -1785,9 +2013,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 +2029,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 +2053,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 +2083,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>
|
||||
|
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
@@ -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) =>
|
||||
|
@@ -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";
|
||||
|
@@ -37,6 +37,11 @@ export default defineConfig({
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
"/admin": {
|
||||
target: `http://${process.env.BACKEND_HOST || "localhost"}:${process.env.BACKEND_PORT || "3001"}`,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
1040
package-lock.json
generated
1040
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user