Add full Oculog codebase
This commit is contained in:
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
35
clients/ubuntu/build-deb.sh
Executable file
35
clients/ubuntu/build-deb.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${GREEN}Building Oculog Client Debian Package${NC}"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Check if dpkg-buildpackage is available
|
||||
if ! command -v dpkg-buildpackage &> /dev/null; then
|
||||
echo -e "${YELLOW}dpkg-buildpackage not found. Installing build dependencies...${NC}"
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y devscripts build-essential debhelper
|
||||
fi
|
||||
|
||||
# Clean previous builds
|
||||
echo "Cleaning previous builds..."
|
||||
rm -rf debian/oculog-client
|
||||
rm -f ../oculog-client_*.deb
|
||||
rm -f ../oculog-client_*.changes
|
||||
rm -f ../oculog-client_*.buildinfo
|
||||
|
||||
# Build the package
|
||||
echo "Building package..."
|
||||
dpkg-buildpackage -b -us -uc
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Package built successfully!${NC}"
|
||||
echo "Package location: ../oculog-client_*.deb"
|
||||
|
||||
47
clients/ubuntu/check-client-version.sh
Executable file
47
clients/ubuntu/check-client-version.sh
Executable file
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
# Quick script to check client version status
|
||||
|
||||
echo "=== Client Version Diagnostic ==="
|
||||
echo ""
|
||||
|
||||
# 1. Check what version is in the file
|
||||
echo "1. Version in client.py file:"
|
||||
if sudo grep -q 'CLIENT_VERSION_BUILD_TIMESTAMP = "' /opt/oculog/client.py 2>/dev/null; then
|
||||
VERSION=$(sudo grep 'CLIENT_VERSION_BUILD_TIMESTAMP = "' /opt/oculog/client.py | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||
echo " ✓ Injected version: $VERSION"
|
||||
else
|
||||
echo " ⚠ No injected version found (using file modification time)"
|
||||
MTIME=$(sudo stat -c %y /opt/oculog/client.py 2>/dev/null | cut -d' ' -f1,2 | sed 's/ /T/' | cut -d'.' -f1 | sed 's/T/-/' | sed 's/:/-/g' | cut -d'-' -f1-5)
|
||||
echo " File modification time: $MTIME"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 2. Check what version the client is reporting
|
||||
echo "2. Version reported by running client:"
|
||||
VERSION_IN_LOG=$(sudo journalctl -u oculog-client.service -n 100 2>/dev/null | grep -i "version:" | tail -1 | sed 's/.*version: \([^,]*\).*/\1/')
|
||||
if [ -n "$VERSION_IN_LOG" ]; then
|
||||
echo " ✓ Client reports: $VERSION_IN_LOG"
|
||||
else
|
||||
echo " ⚠ No version found in recent logs"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 3. Check recent metrics sends
|
||||
echo "3. Recent metrics activity:"
|
||||
RECENT_SENDS=$(sudo journalctl -u oculog-client.service -n 20 2>/dev/null | grep -i "metrics sent successfully" | wc -l)
|
||||
if [ "$RECENT_SENDS" -gt 0 ]; then
|
||||
echo " ✓ Metrics are being sent successfully"
|
||||
echo " Last successful send:"
|
||||
sudo journalctl -u oculog-client.service -n 20 2>/dev/null | grep -i "metrics sent successfully" | tail -1
|
||||
else
|
||||
echo " ⚠ No successful metric sends in recent logs"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Diagnostic Complete ==="
|
||||
echo ""
|
||||
echo "If version injection failed, the client will use file modification time."
|
||||
echo "To force re-injection, run the update script again."
|
||||
|
||||
7
clients/ubuntu/client.conf.example
Normal file
7
clients/ubuntu/client.conf.example
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"server_url": "http://your-server-ip:3001",
|
||||
"server_id": "ubuntu-server-01",
|
||||
"api_key": "your-api-key-here",
|
||||
"interval": 30
|
||||
}
|
||||
|
||||
902
clients/ubuntu/client.py
Executable file
902
clients/ubuntu/client.py
Executable file
@@ -0,0 +1,902 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Oculog Client Agent
|
||||
Collects system metrics and sends them to the server
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
import socket
|
||||
import requests
|
||||
import psutil
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Configuration
|
||||
CONFIG_FILE = '/etc/oculog/client.conf'
|
||||
LOG_FILE = '/var/log/oculog/client.log'
|
||||
PID_FILE = '/var/run/oculog-client.pid'
|
||||
CLIENT_SCRIPT_PATH = '/opt/oculog/client.py'
|
||||
|
||||
# Client version - build timestamp in format year-month-day-hour-minute
|
||||
# This will be injected by the server when serving the script
|
||||
# Format: CLIENT_VERSION_BUILD_TIMESTAMP = "YYYY-MM-DD-HH-MM"
|
||||
# If not injected, will use file modification time
|
||||
CLIENT_VERSION_BUILD_TIMESTAMP = None # Will be injected by server
|
||||
|
||||
def get_client_version():
|
||||
"""Get client version from build timestamp or file modification time"""
|
||||
# First check if build timestamp was injected
|
||||
if CLIENT_VERSION_BUILD_TIMESTAMP:
|
||||
return CLIENT_VERSION_BUILD_TIMESTAMP
|
||||
|
||||
# Fallback to file modification time
|
||||
try:
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
script_path = os.path.join(script_dir, 'client.py')
|
||||
if not os.path.exists(script_path):
|
||||
script_path = CLIENT_SCRIPT_PATH
|
||||
|
||||
if os.path.exists(script_path):
|
||||
mtime = os.path.getmtime(script_path)
|
||||
dt = datetime.fromtimestamp(mtime)
|
||||
return dt.strftime('%Y-%m-%d-%H-%M')
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not determine client version: {e}")
|
||||
|
||||
# Final fallback: use current time (for new installations)
|
||||
return datetime.now().strftime('%Y-%m-%d-%H-%M')
|
||||
|
||||
CLIENT_VERSION = get_client_version()
|
||||
|
||||
# Ensure log directory exists
|
||||
log_dir = os.path.dirname(LOG_FILE)
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(LOG_FILE),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger('oculog-client')
|
||||
|
||||
|
||||
class VulnerabilityScanner:
|
||||
"""Scans for vulnerabilities in installed packages"""
|
||||
|
||||
def __init__(self, enabled=True):
|
||||
self.enabled = enabled
|
||||
self.last_scan_time = 0
|
||||
self.scan_interval = 86400 # 1 day in seconds
|
||||
|
||||
def get_installed_packages(self):
|
||||
"""Get list of all installed packages (excluding removed packages)"""
|
||||
try:
|
||||
# Use dpkg-query with status to filter out removed packages
|
||||
# Status format: wantok installed, config-files, half-configured, etc.
|
||||
# We only want packages with "install ok installed" status
|
||||
result = subprocess.run(
|
||||
['dpkg-query', '-W', '-f=${Status}\t${Package}\t${Version}\n'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"dpkg-query failed: {result.stderr}")
|
||||
return []
|
||||
|
||||
packages = []
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split('\t')
|
||||
if len(parts) >= 3:
|
||||
status, name, version = parts[0], parts[1], parts[2]
|
||||
# Only include packages that are actually installed
|
||||
# Status format: "install ok installed" means fully installed
|
||||
# "deinstall ok config-files" (rc) means removed but config remains - exclude these
|
||||
if 'install ok installed' in status:
|
||||
packages.append({
|
||||
'name': name.strip(),
|
||||
'version': version.strip()
|
||||
})
|
||||
|
||||
return packages
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("dpkg-query timed out")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting installed packages: {e}")
|
||||
return []
|
||||
|
||||
def should_scan(self):
|
||||
"""Check if it's time to run a vulnerability scan"""
|
||||
if not self.enabled:
|
||||
return False
|
||||
current_time = time.time()
|
||||
return (current_time - self.last_scan_time) >= self.scan_interval
|
||||
|
||||
def get_os_version(self):
|
||||
"""Get Ubuntu version from /etc/os-release"""
|
||||
try:
|
||||
if os.path.exists('/etc/os-release'):
|
||||
with open('/etc/os-release', 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line.startswith('VERSION_ID='):
|
||||
# Extract version, remove quotes
|
||||
version = line.split('=', 1)[1].strip('"').strip("'")
|
||||
return version
|
||||
elif line.startswith('VERSION='):
|
||||
# Fallback: try to extract version from VERSION field
|
||||
version_str = line.split('=', 1)[1].strip('"').strip("'")
|
||||
# Try to extract version number (e.g., "22.04" from "22.04 LTS")
|
||||
import re
|
||||
match = re.search(r'(\d+\.\d+)', version_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Error reading OS version: {e}")
|
||||
return None
|
||||
|
||||
def scan(self):
|
||||
"""Perform vulnerability scan"""
|
||||
try:
|
||||
logger.info("Starting vulnerability scan...")
|
||||
packages = self.get_installed_packages()
|
||||
|
||||
if not packages:
|
||||
logger.warning("No packages found to scan")
|
||||
return False
|
||||
|
||||
logger.info(f"Scanning {len(packages)} packages for vulnerabilities")
|
||||
self.last_scan_time = time.time()
|
||||
|
||||
# Return packages with OS version info
|
||||
return {
|
||||
'packages': packages,
|
||||
'os_version': self.get_os_version()
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error during vulnerability scan: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class MetricsCollector:
|
||||
"""Collects system metrics"""
|
||||
|
||||
def __init__(self):
|
||||
self.hostname = socket.gethostname()
|
||||
self.last_network_stats = None
|
||||
self.last_network_time = None
|
||||
# Cache for rarely-changing data
|
||||
self.cached_os_release = None
|
||||
self.cached_public_ip = None
|
||||
self.cached_public_ip_time = 0
|
||||
self.public_ip_cache_duration = 3600 # Cache public IP for 1 hour
|
||||
self.last_docker_check = 0
|
||||
self.docker_check_interval = 300 # Check docker every 5 minutes
|
||||
self.cached_docker_available = None
|
||||
self.cached_containers = None
|
||||
|
||||
def get_cpu_metrics(self):
|
||||
"""Get CPU usage percentage"""
|
||||
try:
|
||||
# Use interval=None to avoid blocking - first call returns 0.0,
|
||||
# but since client runs continuously, subsequent calls will be accurate
|
||||
cpu_percent = psutil.cpu_percent(interval=None)
|
||||
cpu_count = psutil.cpu_count()
|
||||
return {
|
||||
'usage': round(cpu_percent, 2),
|
||||
'cores': cpu_count
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error collecting CPU metrics: {e}")
|
||||
return {'usage': 0, 'cores': 0}
|
||||
|
||||
def get_memory_metrics(self):
|
||||
"""Get memory usage"""
|
||||
try:
|
||||
mem = psutil.virtual_memory()
|
||||
return {
|
||||
'total': round(mem.total / (1024**3), 2), # GB
|
||||
'used': round(mem.used / (1024**3), 2), # GB
|
||||
'available': round(mem.available / (1024**3), 2), # GB
|
||||
'percent': round(mem.percent, 2)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error collecting memory metrics: {e}")
|
||||
return {'total': 0, 'used': 0, 'available': 0, 'percent': 0}
|
||||
|
||||
def get_swap_metrics(self):
|
||||
"""Get swap usage"""
|
||||
try:
|
||||
swap = psutil.swap_memory()
|
||||
return {
|
||||
'total': round(swap.total / (1024**3), 2), # GB
|
||||
'used': round(swap.used / (1024**3), 2), # GB
|
||||
'free': round(swap.free / (1024**3), 2), # GB
|
||||
'percent': round(swap.percent, 2)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error collecting swap metrics: {e}")
|
||||
return {'total': 0, 'used': 0, 'free': 0, 'percent': 0}
|
||||
|
||||
def get_process_count(self):
|
||||
"""Get total process count"""
|
||||
try:
|
||||
# More efficient: use process_iter with a counter instead of creating full list
|
||||
count = 0
|
||||
for _ in psutil.process_iter():
|
||||
count += 1
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.error(f"Error collecting process count: {e}")
|
||||
return 0
|
||||
|
||||
def get_disk_metrics(self):
|
||||
"""Get disk usage for all mounted filesystems"""
|
||||
try:
|
||||
disks = []
|
||||
partitions = psutil.disk_partitions()
|
||||
|
||||
for partition in partitions:
|
||||
try:
|
||||
# Skip virtual filesystems and network mounts
|
||||
if partition.fstype in ['tmpfs', 'devtmpfs', 'sysfs', 'proc', 'devpts', 'cgroup', 'cgroup2', 'pstore', 'bpf', 'tracefs', 'debugfs', 'securityfs', 'hugetlbfs', 'mqueue', 'overlay', 'autofs', 'squashfs']:
|
||||
continue
|
||||
|
||||
# Skip network filesystems (optional - comment out if you want to include them)
|
||||
if partition.fstype.startswith('nfs') or partition.fstype.startswith('cifs') or partition.fstype.startswith('smb'):
|
||||
continue
|
||||
|
||||
# Skip loop devices (snap packages, etc.)
|
||||
if partition.device.startswith('/dev/loop'):
|
||||
continue
|
||||
|
||||
# Skip snap mount points
|
||||
if partition.mountpoint.startswith('/snap/'):
|
||||
continue
|
||||
|
||||
disk_usage = psutil.disk_usage(partition.mountpoint)
|
||||
disk_info = {
|
||||
'mountpoint': partition.mountpoint,
|
||||
'device': partition.device,
|
||||
'fstype': partition.fstype,
|
||||
'total': round(disk_usage.total / (1024**3), 2), # GB
|
||||
'used': round(disk_usage.used / (1024**3), 2), # GB
|
||||
'free': round(disk_usage.free / (1024**3), 2), # GB
|
||||
'percent': round(disk_usage.percent, 2)
|
||||
}
|
||||
disks.append(disk_info)
|
||||
logger.debug(f"Collected disk metrics: {partition.mountpoint} ({partition.device}) - {disk_info['used']:.2f}GB / {disk_info['total']:.2f}GB ({disk_info['percent']:.1f}%)")
|
||||
except PermissionError:
|
||||
# Skip partitions we don't have permission to access
|
||||
logger.debug(f"Skipping {partition.mountpoint} due to permission error")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting disk usage for {partition.mountpoint}: {e}")
|
||||
continue
|
||||
|
||||
# Return root disk for backward compatibility, plus all disks
|
||||
root_disk = next((d for d in disks if d['mountpoint'] == '/'), disks[0] if disks else None)
|
||||
|
||||
logger.info(f"Collected metrics for {len(disks)} disk(s): {[d['mountpoint'] for d in disks]}")
|
||||
|
||||
if root_disk:
|
||||
return {
|
||||
'total': root_disk['total'],
|
||||
'used': root_disk['used'],
|
||||
'free': root_disk['free'],
|
||||
'percent': root_disk['percent'],
|
||||
'disks': disks # Include all disks
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'total': 0,
|
||||
'used': 0,
|
||||
'free': 0,
|
||||
'percent': 0,
|
||||
'disks': disks
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error collecting disk metrics: {e}")
|
||||
return {'total': 0, 'used': 0, 'free': 0, 'percent': 0, 'disks': []}
|
||||
|
||||
def get_network_metrics(self):
|
||||
"""Get network throughput"""
|
||||
try:
|
||||
net_io = psutil.net_io_counters()
|
||||
current_time = time.time()
|
||||
|
||||
if self.last_network_stats is None:
|
||||
self.last_network_stats = net_io
|
||||
self.last_network_time = current_time
|
||||
return {'rx': 0, 'tx': 0, 'rx_total': 0, 'tx_total': 0}
|
||||
|
||||
time_delta = current_time - self.last_network_time
|
||||
if time_delta == 0:
|
||||
return {'rx': 0, 'tx': 0, 'rx_total': 0, 'tx_total': 0}
|
||||
|
||||
rx_bytes = net_io.bytes_recv - self.last_network_stats.bytes_recv
|
||||
tx_bytes = net_io.bytes_sent - self.last_network_stats.bytes_sent
|
||||
|
||||
rx_mbps = (rx_bytes * 8) / (time_delta * 1024 * 1024) # Mbps
|
||||
tx_mbps = (tx_bytes * 8) / (time_delta * 1024 * 1024) # Mbps
|
||||
|
||||
self.last_network_stats = net_io
|
||||
self.last_network_time = current_time
|
||||
|
||||
return {
|
||||
'rx': round(rx_mbps, 2), # Mbps
|
||||
'tx': round(tx_mbps, 2), # Mbps
|
||||
'rx_total': round(net_io.bytes_recv / (1024**3), 2), # GB
|
||||
'tx_total': round(net_io.bytes_sent / (1024**3), 2) # GB
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error collecting network metrics: {e}")
|
||||
return {'rx': 0, 'tx': 0, 'rx_total': 0, 'tx_total': 0}
|
||||
|
||||
def get_server_info(self):
|
||||
"""Get server information: OS release, status, processes, containers, IP info"""
|
||||
server_info = {
|
||||
'os_release': None,
|
||||
'live_status': None,
|
||||
'top_processes': None,
|
||||
'containers': None,
|
||||
'ip_info': None
|
||||
}
|
||||
|
||||
# Get OS release info (cached since it rarely changes)
|
||||
if self.cached_os_release is None:
|
||||
try:
|
||||
os_release = {}
|
||||
if os.path.exists('/etc/os-release'):
|
||||
with open('/etc/os-release', 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if '=' in line and not line.startswith('#'):
|
||||
key, value = line.split('=', 1)
|
||||
# Remove quotes from value
|
||||
value = value.strip('"').strip("'")
|
||||
os_release[key] = value
|
||||
self.cached_os_release = os_release if os_release else None
|
||||
except Exception as e:
|
||||
logger.warning(f"Error reading /etc/os-release: {e}")
|
||||
self.cached_os_release = None
|
||||
server_info['os_release'] = self.cached_os_release
|
||||
|
||||
# Get live status (uptime, load average)
|
||||
try:
|
||||
uptime_seconds = time.time() - psutil.boot_time()
|
||||
uptime_days = int(uptime_seconds // 86400)
|
||||
uptime_hours = int((uptime_seconds % 86400) // 3600)
|
||||
uptime_minutes = int((uptime_seconds % 3600) // 60)
|
||||
|
||||
load_avg = os.getloadavg()
|
||||
|
||||
server_info['live_status'] = {
|
||||
'uptime_days': uptime_days,
|
||||
'uptime_hours': uptime_hours,
|
||||
'uptime_minutes': uptime_minutes,
|
||||
'uptime_seconds': round(uptime_seconds, 2),
|
||||
'load_average_1min': round(load_avg[0], 2),
|
||||
'load_average_5min': round(load_avg[1], 2),
|
||||
'load_average_15min': round(load_avg[2], 2)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting live status: {e}")
|
||||
|
||||
# Get top processes by CPU usage
|
||||
try:
|
||||
# Collect CPU usage for all processes, then sort to get actual top processes
|
||||
# This ensures we find the highest CPU-consuming processes regardless of process order
|
||||
processes = []
|
||||
|
||||
for proc in psutil.process_iter(['pid', 'name', 'memory_percent', 'username']):
|
||||
try:
|
||||
# Get CPU percent - first call may return 0.0, but that's acceptable
|
||||
# The client runs continuously so subsequent calls will have accurate values
|
||||
cpu_pct = proc.cpu_percent(interval=None)
|
||||
if cpu_pct is None:
|
||||
cpu_pct = 0.0
|
||||
|
||||
proc_info = proc.info
|
||||
proc_info['cpu_percent'] = round(cpu_pct, 2)
|
||||
processes.append(proc_info)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
continue
|
||||
|
||||
# Sort by CPU usage and take top 10
|
||||
processes.sort(key=lambda x: x.get('cpu_percent', 0), reverse=True)
|
||||
server_info['top_processes'] = processes[:10]
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting top processes: {e}")
|
||||
|
||||
# Get running containers (Docker) - check less frequently
|
||||
current_time = time.time()
|
||||
if current_time - self.last_docker_check >= self.docker_check_interval:
|
||||
self.last_docker_check = current_time
|
||||
try:
|
||||
# First check if docker is available (cached)
|
||||
if self.cached_docker_available is None:
|
||||
# Check if docker command exists
|
||||
docker_check = subprocess.run(
|
||||
['which', 'docker'],
|
||||
capture_output=True,
|
||||
timeout=1
|
||||
)
|
||||
self.cached_docker_available = docker_check.returncode == 0
|
||||
|
||||
if self.cached_docker_available:
|
||||
result = subprocess.run(
|
||||
['docker', 'ps', '--format', 'json'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
containers = []
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if line.strip():
|
||||
try:
|
||||
container = json.loads(line)
|
||||
containers.append({
|
||||
'id': container.get('ID', '')[:12],
|
||||
'image': container.get('Image', ''),
|
||||
'command': container.get('Command', ''),
|
||||
'created': container.get('CreatedAt', ''),
|
||||
'status': container.get('Status', ''),
|
||||
'ports': container.get('Ports', ''),
|
||||
'names': container.get('Names', '')
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
self.cached_containers = containers if containers else None
|
||||
else:
|
||||
self.cached_containers = None
|
||||
else:
|
||||
self.cached_containers = None
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, Exception) as e:
|
||||
# Docker might not be installed or not running
|
||||
self.cached_docker_available = False
|
||||
self.cached_containers = None
|
||||
|
||||
# Use cached containers value
|
||||
server_info['containers'] = self.cached_containers
|
||||
|
||||
# Get IP info (private and public)
|
||||
try:
|
||||
ip_info = {
|
||||
'private': [],
|
||||
'public': None
|
||||
}
|
||||
|
||||
# Get private IPs from network interfaces
|
||||
for interface, addrs in psutil.net_if_addrs().items():
|
||||
for addr in addrs:
|
||||
if addr.family == socket.AF_INET: # IPv4
|
||||
ip_addr = addr.address
|
||||
# Skip loopback and link-local
|
||||
if not ip_addr.startswith('127.') and not ip_addr.startswith('169.254.'):
|
||||
ip_info['private'].append({
|
||||
'interface': interface,
|
||||
'ip': ip_addr,
|
||||
'netmask': addr.netmask
|
||||
})
|
||||
|
||||
# Try to get public IP (cached since it rarely changes)
|
||||
current_time = time.time()
|
||||
if self.cached_public_ip is None or (current_time - self.cached_public_ip_time) >= self.public_ip_cache_duration:
|
||||
try:
|
||||
# Try multiple services for reliability
|
||||
public_ip_services = [
|
||||
'https://api.ipify.org?format=json',
|
||||
'https://ifconfig.me/ip',
|
||||
'https://icanhazip.com'
|
||||
]
|
||||
|
||||
for service_url in public_ip_services:
|
||||
try:
|
||||
response = requests.get(service_url, timeout=3)
|
||||
if response.status_code == 200:
|
||||
# Handle different response formats
|
||||
if 'ipify' in service_url:
|
||||
public_ip = response.json().get('ip', '').strip()
|
||||
else:
|
||||
public_ip = response.text.strip()
|
||||
|
||||
if public_ip:
|
||||
self.cached_public_ip = public_ip
|
||||
self.cached_public_ip_time = current_time
|
||||
ip_info['public'] = public_ip
|
||||
break
|
||||
except:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not fetch public IP: {e}")
|
||||
else:
|
||||
# Use cached public IP
|
||||
ip_info['public'] = self.cached_public_ip
|
||||
|
||||
server_info['ip_info'] = ip_info if ip_info['private'] or ip_info['public'] else None
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting IP info: {e}")
|
||||
|
||||
return server_info
|
||||
|
||||
def collect_all_metrics(self):
|
||||
"""Collect all system metrics"""
|
||||
server_info = self.get_server_info()
|
||||
|
||||
# Extract load average and uptime from server_info for metrics payload
|
||||
load_avg = None
|
||||
uptime_seconds = None
|
||||
if server_info.get('live_status'):
|
||||
live_status = server_info['live_status']
|
||||
load_avg = {
|
||||
'1min': live_status.get('load_average_1min'),
|
||||
'5min': live_status.get('load_average_5min'),
|
||||
'15min': live_status.get('load_average_15min')
|
||||
}
|
||||
uptime_seconds = live_status.get('uptime_seconds')
|
||||
|
||||
return {
|
||||
'hostname': self.hostname,
|
||||
'timestamp': datetime.now(timezone.utc).isoformat(),
|
||||
'cpu': self.get_cpu_metrics(),
|
||||
'memory': self.get_memory_metrics(),
|
||||
'swap': self.get_swap_metrics(),
|
||||
'disk': self.get_disk_metrics(),
|
||||
'network': self.get_network_metrics(),
|
||||
'process_count': self.get_process_count(),
|
||||
'load_avg': load_avg,
|
||||
'uptime_seconds': uptime_seconds,
|
||||
'server_info': server_info,
|
||||
'client_version': CLIENT_VERSION
|
||||
}
|
||||
|
||||
|
||||
class OculogClient:
|
||||
"""Main client class"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.server_url = config.get('server_url', 'http://localhost:3001')
|
||||
self.server_id = config.get('server_id', socket.gethostname())
|
||||
self.api_key = config.get('api_key', '')
|
||||
self.interval = config.get('interval', 30) # seconds
|
||||
self.collector = MetricsCollector()
|
||||
# Vulnerability scanning is enabled by default, but can be disabled via config
|
||||
vulnerability_scan_enabled = config.get('vulnerability_scan_enabled', True)
|
||||
self.vulnerability_scanner = VulnerabilityScanner(enabled=vulnerability_scan_enabled)
|
||||
self.running = False
|
||||
self.last_update_check = 0
|
||||
self.update_check_interval = 3600 # Check for updates every hour
|
||||
|
||||
def send_metrics(self, metrics):
|
||||
"""Send metrics to server"""
|
||||
try:
|
||||
url = f"{self.server_url}/api/servers/{self.server_id}/metrics"
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': self.api_key
|
||||
}
|
||||
|
||||
if not self.api_key:
|
||||
logger.warning("No API key configured. Metrics may be rejected by server.")
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
json=metrics,
|
||||
timeout=10,
|
||||
headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info(f"Metrics sent successfully: {response.json()}")
|
||||
return True
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to send metrics: {e}")
|
||||
return False
|
||||
|
||||
def send_vulnerability_scan(self, scan_data):
|
||||
"""Send vulnerability scan results to server in batches if needed"""
|
||||
try:
|
||||
url = f"{self.server_url}/api/servers/{self.server_id}/vulnerabilities/scan"
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': self.api_key
|
||||
}
|
||||
|
||||
if not self.api_key:
|
||||
logger.warning("No API key configured. Vulnerability scan may be rejected by server.")
|
||||
return False
|
||||
|
||||
# Extract packages and os_version
|
||||
if isinstance(scan_data, dict) and 'packages' in scan_data:
|
||||
packages = scan_data['packages']
|
||||
os_version = scan_data.get('os_version')
|
||||
else:
|
||||
# Legacy format: just a list of packages
|
||||
packages = scan_data
|
||||
os_version = None
|
||||
|
||||
# Batch packages to avoid payload size limits (send 500 packages per batch)
|
||||
batch_size = 500
|
||||
total_packages = len(packages)
|
||||
total_vulnerabilities = 0
|
||||
success_count = 0
|
||||
|
||||
logger.info(f"Sending vulnerability scan for {total_packages} packages in batches of {batch_size}")
|
||||
|
||||
for i in range(0, total_packages, batch_size):
|
||||
batch = packages[i:i + batch_size]
|
||||
batch_num = (i // batch_size) + 1
|
||||
total_batches = (total_packages + batch_size - 1) // batch_size
|
||||
|
||||
payload = {
|
||||
'packages': batch,
|
||||
'os_version': os_version,
|
||||
'batch_info': {
|
||||
'batch_number': batch_num,
|
||||
'total_batches': total_batches,
|
||||
'is_last_batch': (i + batch_size >= total_packages)
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
json=payload,
|
||||
timeout=120, # Longer timeout for vulnerability scans
|
||||
headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
batch_vulns = result.get('vulnerabilities_found', 0)
|
||||
total_vulnerabilities += batch_vulns
|
||||
success_count += 1
|
||||
logger.info(f"Batch {batch_num}/{total_batches} sent successfully: {len(batch)} packages, {batch_vulns} vulnerabilities found")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to send vulnerability scan batch {batch_num}/{total_batches}: {e}")
|
||||
# Continue with other batches even if one fails
|
||||
continue
|
||||
|
||||
if success_count > 0:
|
||||
logger.info(f"Vulnerability scan completed: {success_count}/{total_batches} batches successful, {total_vulnerabilities} total vulnerabilities found")
|
||||
return True
|
||||
else:
|
||||
logger.error("All vulnerability scan batches failed")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send vulnerability scan: {e}")
|
||||
return False
|
||||
|
||||
def check_for_updates(self):
|
||||
"""Check if a newer client version is available"""
|
||||
try:
|
||||
url = f"{self.server_url}/api/client-version/latest"
|
||||
headers = {
|
||||
'X-API-Key': self.api_key
|
||||
}
|
||||
|
||||
logger.debug(f"Checking for updates: current version={CLIENT_VERSION}, server={url}")
|
||||
response = requests.get(url, timeout=5, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
latest_version = data.get('version')
|
||||
|
||||
if not latest_version:
|
||||
logger.warning(f"Update check returned no version data from server")
|
||||
return False
|
||||
|
||||
logger.debug(f"Server reports latest version: {latest_version}")
|
||||
|
||||
# Compare versions (format: YYYY-MM-DD-HH-MM)
|
||||
# Simple string comparison works for this format
|
||||
if CLIENT_VERSION < latest_version:
|
||||
logger.info(f"Update available: current={CLIENT_VERSION}, latest={latest_version}")
|
||||
return True
|
||||
else:
|
||||
logger.debug(f"No update needed: current={CLIENT_VERSION}, latest={latest_version}")
|
||||
|
||||
return False
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"Could not check for updates (network error): {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not check for updates (unexpected error): {e}")
|
||||
return False
|
||||
|
||||
def perform_auto_update(self):
|
||||
"""Perform automatic update of the client"""
|
||||
try:
|
||||
logger.info("Starting automatic client update...")
|
||||
|
||||
# Download updated client script
|
||||
script_url = f"{self.server_url}/api/client-script"
|
||||
temp_script = '/opt/oculog/client.py.new'
|
||||
|
||||
response = requests.get(script_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
# Backup current script
|
||||
if os.path.exists(CLIENT_SCRIPT_PATH):
|
||||
backup_path = f"{CLIENT_SCRIPT_PATH}.backup"
|
||||
subprocess.run(['cp', CLIENT_SCRIPT_PATH, backup_path], check=False)
|
||||
|
||||
# Write new script
|
||||
with open(temp_script, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
# Make executable
|
||||
os.chmod(temp_script, 0o755)
|
||||
|
||||
# Replace old script atomically
|
||||
subprocess.run(['mv', temp_script, CLIENT_SCRIPT_PATH], check=True)
|
||||
|
||||
logger.info("Client script updated successfully")
|
||||
|
||||
# Trigger systemd restart by exiting (systemd will restart due to Restart=always)
|
||||
# We use exit code 0 to indicate successful update
|
||||
logger.info("Exiting to allow systemd to restart with new version")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Auto-update failed: {e}")
|
||||
# Try to restore backup if update failed
|
||||
backup_path = f"{CLIENT_SCRIPT_PATH}.backup"
|
||||
if os.path.exists(backup_path):
|
||||
try:
|
||||
subprocess.run(['cp', backup_path, CLIENT_SCRIPT_PATH], check=False)
|
||||
logger.info("Restored backup after failed update")
|
||||
except:
|
||||
pass
|
||||
return False
|
||||
|
||||
def run(self):
|
||||
"""Main loop"""
|
||||
self.running = True
|
||||
logger.info(f"Starting Oculog client (server_id: {self.server_id}, version: {CLIENT_VERSION}, interval: {self.interval}s)")
|
||||
|
||||
# Initial network stats collection (needed for first calculation)
|
||||
self.collector.get_network_metrics()
|
||||
time.sleep(1)
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# Check for updates periodically
|
||||
current_time = time.time()
|
||||
if current_time - self.last_update_check >= self.update_check_interval:
|
||||
logger.info(f"Checking for client updates (current version: {CLIENT_VERSION})...")
|
||||
self.last_update_check = current_time
|
||||
try:
|
||||
if self.check_for_updates():
|
||||
logger.info("Newer version detected, performing auto-update...")
|
||||
if self.perform_auto_update():
|
||||
logger.info("Auto-update completed, exiting to allow restart...")
|
||||
# Exit gracefully - systemd will restart with Restart=always
|
||||
self.running = False
|
||||
sys.exit(0)
|
||||
else:
|
||||
logger.warning("Auto-update failed, continuing with current version")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during update check: {e}")
|
||||
# Continue running even if update check fails
|
||||
|
||||
# Check for vulnerability scan (hourly)
|
||||
if self.vulnerability_scanner.should_scan():
|
||||
try:
|
||||
scan_data = self.vulnerability_scanner.scan()
|
||||
if scan_data:
|
||||
package_count = len(scan_data['packages']) if isinstance(scan_data, dict) else len(scan_data)
|
||||
logger.info(f"Sending vulnerability scan for {package_count} packages...")
|
||||
if self.send_vulnerability_scan(scan_data):
|
||||
logger.info("Vulnerability scan completed successfully")
|
||||
else:
|
||||
logger.warning("Vulnerability scan failed to send, will retry on next cycle")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during vulnerability scan: {e}")
|
||||
# Continue running even if vulnerability scan fails
|
||||
|
||||
metrics = self.collector.collect_all_metrics()
|
||||
logger.debug(f"Collected metrics: {json.dumps(metrics, indent=2)}")
|
||||
|
||||
if self.send_metrics(metrics):
|
||||
logger.info("Metrics collection cycle completed successfully")
|
||||
else:
|
||||
logger.warning("Failed to send metrics, will retry on next cycle")
|
||||
|
||||
time.sleep(self.interval)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Received interrupt signal, shutting down...")
|
||||
self.running = False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in main loop: {e}")
|
||||
time.sleep(self.interval)
|
||||
|
||||
def stop(self):
|
||||
"""Stop the client"""
|
||||
self.running = False
|
||||
|
||||
|
||||
def load_config():
|
||||
"""Load configuration from file"""
|
||||
default_config = {
|
||||
'server_url': 'http://localhost:3001',
|
||||
'server_id': socket.gethostname(),
|
||||
'interval': 30
|
||||
}
|
||||
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
with open(CONFIG_FILE, 'r') as f:
|
||||
config = json.load(f)
|
||||
default_config.update(config)
|
||||
logger.info(f"Loaded configuration from {CONFIG_FILE}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load config file, using defaults: {e}")
|
||||
else:
|
||||
logger.info(f"Config file not found at {CONFIG_FILE}, using defaults")
|
||||
|
||||
return default_config
|
||||
|
||||
|
||||
def write_pid_file():
|
||||
"""Write PID file"""
|
||||
try:
|
||||
pid_dir = os.path.dirname(PID_FILE)
|
||||
os.makedirs(pid_dir, exist_ok=True)
|
||||
with open(PID_FILE, 'w') as f:
|
||||
f.write(str(os.getpid()))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write PID file: {e}")
|
||||
|
||||
|
||||
def remove_pid_file():
|
||||
"""Remove PID file"""
|
||||
try:
|
||||
if os.path.exists(PID_FILE):
|
||||
os.remove(PID_FILE)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove PID file: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
if len(sys.argv) > 1:
|
||||
if sys.argv[1] == '--version':
|
||||
print(f"Oculog Client version {CLIENT_VERSION}")
|
||||
sys.exit(0)
|
||||
elif sys.argv[1] == '--help':
|
||||
print("Usage: oculog-client [--version|--help]")
|
||||
print("\nOculog Client Agent")
|
||||
print("Collects system metrics and sends them to the server")
|
||||
sys.exit(0)
|
||||
|
||||
# Ensure log directory exists
|
||||
log_dir = os.path.dirname(LOG_FILE)
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
config = load_config()
|
||||
client = OculogClient(config)
|
||||
|
||||
try:
|
||||
write_pid_file()
|
||||
client.run()
|
||||
finally:
|
||||
remove_pid_file()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
10
clients/ubuntu/debian/control
Normal file
10
clients/ubuntu/debian/control
Normal file
@@ -0,0 +1,10 @@
|
||||
Package: oculog-client
|
||||
Version: 1.0.0
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Architecture: all
|
||||
Depends: python3 (>= 3.6), python3-pip
|
||||
Maintainer: Ormentia <support@ormentia.com>
|
||||
Description: Oculog Client Agent for Server Metrics Collection
|
||||
Oculog client agent that collects system metrics (CPU, memory, disk, network)
|
||||
and sends them to the Oculog observability platform server.
|
||||
55
clients/ubuntu/debian/postinst
Executable file
55
clients/ubuntu/debian/postinst
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Post-installation script
|
||||
echo "Configuring Oculog client..."
|
||||
|
||||
# Create directories
|
||||
mkdir -p /var/log/oculog
|
||||
mkdir -p /etc/oculog
|
||||
|
||||
# Install Python dependencies using system-managed approach
|
||||
# Try to install via apt first (for newer Ubuntu versions)
|
||||
if apt-cache show python3-psutil >/dev/null 2>&1 && apt-cache show python3-requests >/dev/null 2>&1; then
|
||||
apt-get update -qq
|
||||
if apt-get install -y -qq python3-psutil python3-requests >/dev/null 2>&1; then
|
||||
PYTHON_BIN="/usr/bin/python3"
|
||||
else
|
||||
USE_VENV=1
|
||||
fi
|
||||
else
|
||||
USE_VENV=1
|
||||
fi
|
||||
|
||||
# If apt packages aren't available, use a virtual environment
|
||||
if [ "$USE_VENV" = "1" ]; then
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq python3-venv python3-pip >/dev/null 2>&1 || true
|
||||
|
||||
# Create virtual environment
|
||||
python3 -m venv /opt/oculog/venv
|
||||
|
||||
# Install packages in virtual environment
|
||||
/opt/oculog/venv/bin/pip install --quiet --upgrade pip
|
||||
/opt/oculog/venv/bin/pip install --quiet psutil==5.9.6 requests==2.31.0
|
||||
|
||||
PYTHON_BIN="/opt/oculog/venv/bin/python3"
|
||||
|
||||
# Update shebang in client script
|
||||
sed -i "1s|.*|#!${PYTHON_BIN}|" /opt/oculog/client.py
|
||||
|
||||
# Update systemd service to use venv Python
|
||||
sed -i "s|ExecStart=.*|ExecStart=${PYTHON_BIN} /opt/oculog/client.py|" /etc/systemd/system/oculog-client.service
|
||||
fi
|
||||
|
||||
# Enable and start service if config exists
|
||||
if [ -f /etc/oculog/client.conf ]; then
|
||||
systemctl daemon-reload
|
||||
systemctl enable oculog-client.service 2>/dev/null || true
|
||||
systemctl start oculog-client.service 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "Oculog client installed successfully!"
|
||||
echo "Please configure /etc/oculog/client.conf before starting the service."
|
||||
|
||||
exit 0
|
||||
26
clients/ubuntu/debian/postrm
Executable file
26
clients/ubuntu/debian/postrm
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Post-removal script
|
||||
if [ "$1" = "remove" ] || [ "$1" = "purge" ]; then
|
||||
# Stop and disable service
|
||||
systemctl stop oculog-client.service 2>/dev/null || true
|
||||
systemctl disable oculog-client.service 2>/dev/null || true
|
||||
|
||||
# Remove service file
|
||||
rm -f /etc/systemd/system/oculog-client.service
|
||||
systemctl daemon-reload
|
||||
|
||||
# Remove symlink
|
||||
rm -f /usr/local/bin/oculog-client
|
||||
|
||||
if [ "$1" = "purge" ]; then
|
||||
# Remove configuration and logs (optional - commented out to preserve data)
|
||||
# rm -rf /etc/oculog
|
||||
# rm -rf /var/log/oculog
|
||||
# rm -rf /opt/oculog
|
||||
echo "Configuration and logs preserved at /etc/oculog and /var/log/oculog"
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
16
clients/ubuntu/debian/rules
Executable file
16
clients/ubuntu/debian/rules
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/make -f
|
||||
|
||||
%:
|
||||
dh $@
|
||||
|
||||
override_dh_auto_install:
|
||||
dh_auto_install
|
||||
# Install client script
|
||||
install -d $(CURDIR)/debian/oculog-client/opt/oculog
|
||||
install -m 755 client.py $(CURDIR)/debian/oculog-client/opt/oculog/
|
||||
# Install systemd service
|
||||
install -d $(CURDIR)/debian/oculog-client/etc/systemd/system
|
||||
install -m 644 oculog-client.service $(CURDIR)/debian/oculog-client/etc/systemd/system/
|
||||
# Create symlink
|
||||
install -d $(CURDIR)/debian/oculog-client/usr/local/bin
|
||||
ln -sf /opt/oculog/client.py $(CURDIR)/debian/oculog-client/usr/local/bin/oculog-client
|
||||
189
clients/ubuntu/install.sh
Executable file
189
clients/ubuntu/install.sh
Executable file
@@ -0,0 +1,189 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}Oculog Client Installation${NC}"
|
||||
echo "=================================="
|
||||
echo ""
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}Please run as root (use sudo)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if Python 3 is installed
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
echo -e "${RED}Python 3 is not installed. Please install it first:${NC}"
|
||||
echo " sudo apt update && sudo apt install -y python3 python3-pip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get installation directory
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
INSTALL_DIR="/opt/oculog"
|
||||
|
||||
# Check if this is an upgrade
|
||||
IS_UPGRADE=false
|
||||
SERVICE_WAS_RUNNING=false
|
||||
SERVICE_WAS_ENABLED=false
|
||||
|
||||
# Check if service unit file exists
|
||||
if [ -f /etc/systemd/system/oculog-client.service ] || systemctl list-unit-files | grep -q "^oculog-client.service"; then
|
||||
IS_UPGRADE=true
|
||||
echo -e "${YELLOW}Detected existing installation - performing upgrade...${NC}"
|
||||
echo ""
|
||||
|
||||
# Check if service is enabled
|
||||
if systemctl is-enabled --quiet oculog-client.service 2>/dev/null; then
|
||||
SERVICE_WAS_ENABLED=true
|
||||
fi
|
||||
|
||||
# Check if service is running
|
||||
if systemctl is-active --quiet oculog-client.service 2>/dev/null; then
|
||||
SERVICE_WAS_RUNNING=true
|
||||
echo -e "${YELLOW}Service is currently running. Stopping it...${NC}"
|
||||
systemctl stop oculog-client.service || true
|
||||
sleep 1
|
||||
if systemctl is-active --quiet oculog-client.service 2>/dev/null; then
|
||||
echo -e "${RED}Warning: Service did not stop cleanly${NC}"
|
||||
else
|
||||
echo -e "${GREEN}Service stopped${NC}"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo -e "${GREEN}Fresh installation detected${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Step 1: Creating installation directory...${NC}"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
mkdir -p /var/log/oculog
|
||||
mkdir -p /etc/oculog
|
||||
|
||||
echo -e "${GREEN}Step 2: Copying client files...${NC}"
|
||||
cp "$SCRIPT_DIR/client.py" "$INSTALL_DIR/"
|
||||
chmod +x "$INSTALL_DIR/client.py"
|
||||
|
||||
echo -e "${GREEN}Step 3: Installing Python dependencies...${NC}"
|
||||
|
||||
# Try to install via apt first (for newer Ubuntu versions with externally-managed environment)
|
||||
if apt-cache show python3-psutil >/dev/null 2>&1 && apt-cache show python3-requests >/dev/null 2>&1; then
|
||||
echo -e "${YELLOW}Installing via apt (system-managed packages)...${NC}"
|
||||
apt-get update
|
||||
if apt-get install -y python3-psutil python3-requests; then
|
||||
PYTHON_BIN="/usr/bin/python3"
|
||||
echo -e "${GREEN}System packages installed successfully${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}Apt installation failed, using virtual environment...${NC}"
|
||||
USE_VENV=1
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}System packages not available, using virtual environment...${NC}"
|
||||
USE_VENV=1
|
||||
fi
|
||||
|
||||
# If apt packages aren't available, use a virtual environment
|
||||
if [ "$USE_VENV" = "1" ]; then
|
||||
if [ -d "$INSTALL_DIR/venv" ]; then
|
||||
echo -e "${YELLOW}Updating existing virtual environment...${NC}"
|
||||
PYTHON_BIN="$INSTALL_DIR/venv/bin/python3"
|
||||
else
|
||||
echo -e "${YELLOW}Setting up virtual environment...${NC}"
|
||||
apt-get update
|
||||
apt-get install -y python3-venv python3-pip || true
|
||||
|
||||
# Create virtual environment
|
||||
python3 -m venv "$INSTALL_DIR/venv"
|
||||
PYTHON_BIN="$INSTALL_DIR/venv/bin/python3"
|
||||
fi
|
||||
|
||||
# Install/upgrade packages in virtual environment
|
||||
"$INSTALL_DIR/venv/bin/pip" install --quiet --upgrade pip
|
||||
"$INSTALL_DIR/venv/bin/pip" install --quiet --upgrade -r "$SCRIPT_DIR/requirements.txt"
|
||||
|
||||
echo -e "${GREEN}Virtual environment ready and packages installed${NC}"
|
||||
fi
|
||||
|
||||
# Update shebang in client script to use correct Python
|
||||
sed -i "1s|.*|#!${PYTHON_BIN}|" "$INSTALL_DIR/client.py"
|
||||
|
||||
echo -e "${GREEN}Step 4: Creating symlink...${NC}"
|
||||
ln -sf "$INSTALL_DIR/client.py" /usr/local/bin/oculog-client
|
||||
|
||||
echo -e "${GREEN}Step 5: Setting up configuration...${NC}"
|
||||
if [ ! -f /etc/oculog/client.conf ]; then
|
||||
# Fresh installation - create new config
|
||||
if [ -f "$SCRIPT_DIR/client.conf.example" ]; then
|
||||
cp "$SCRIPT_DIR/client.conf.example" /etc/oculog/client.conf
|
||||
echo -e "${YELLOW}Configuration file created at /etc/oculog/client.conf${NC}"
|
||||
echo -e "${YELLOW}Please edit it with your server URL and server ID${NC}"
|
||||
else
|
||||
cat > /etc/oculog/client.conf << EOF
|
||||
{
|
||||
"server_url": "http://localhost:3001",
|
||||
"server_id": "$(hostname)",
|
||||
"interval": 30
|
||||
}
|
||||
EOF
|
||||
echo -e "${YELLOW}Default configuration created at /etc/oculog/client.conf${NC}"
|
||||
fi
|
||||
else
|
||||
# Existing installation - preserve config
|
||||
if [ "$IS_UPGRADE" = true ]; then
|
||||
# Backup existing config before upgrade
|
||||
BACKUP_FILE="/etc/oculog/client.conf.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
cp /etc/oculog/client.conf "$BACKUP_FILE"
|
||||
echo -e "${GREEN}Configuration file preserved (backup: $BACKUP_FILE)${NC}"
|
||||
else
|
||||
echo -e "${GREEN}Configuration file already exists, preserving...${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Step 6: Installing systemd service...${NC}"
|
||||
# Update service file to use correct Python binary
|
||||
sed "s|ExecStart=/usr/local/bin/oculog-client|ExecStart=${PYTHON_BIN} $INSTALL_DIR/client.py|" "$SCRIPT_DIR/oculog-client.service" > /etc/systemd/system/oculog-client.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable oculog-client.service
|
||||
|
||||
# Restart service if it was running before upgrade
|
||||
if [ "$IS_UPGRADE" = true ]; then
|
||||
if [ "$SERVICE_WAS_RUNNING" = true ] || [ "$SERVICE_WAS_ENABLED" = true ]; then
|
||||
echo -e "${GREEN}Step 7: Starting service...${NC}"
|
||||
systemctl start oculog-client.service || true
|
||||
sleep 2
|
||||
if systemctl is-active --quiet oculog-client.service 2>/dev/null; then
|
||||
echo -e "${GREEN}Service started successfully${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}Warning: Service may have failed to start. Check status with: systemctl status oculog-client${NC}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ "$IS_UPGRADE" = true ]; then
|
||||
echo -e "${GREEN}Upgrade completed successfully!${NC}"
|
||||
else
|
||||
echo -e "${GREEN}Installation completed successfully!${NC}"
|
||||
fi
|
||||
echo ""
|
||||
if [ "$IS_UPGRADE" = false ]; then
|
||||
echo "Next steps:"
|
||||
echo "1. Edit /etc/oculog/client.conf with your server URL"
|
||||
echo "2. Start the service: sudo systemctl start oculog-client"
|
||||
echo "3. Check status: sudo systemctl status oculog-client"
|
||||
echo "4. View logs: sudo journalctl -u oculog-client -f"
|
||||
else
|
||||
echo "Upgrade complete. Service status:"
|
||||
systemctl status oculog-client.service --no-pager -l || true
|
||||
echo ""
|
||||
echo "View logs: sudo journalctl -u oculog-client -f"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
15
clients/ubuntu/oculog-client.service
Normal file
15
clients/ubuntu/oculog-client.service
Normal file
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Oculog Client Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/local/bin/oculog-client
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
3
clients/ubuntu/requirements.txt
Normal file
3
clients/ubuntu/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
psutil==5.9.6
|
||||
requests==2.31.0
|
||||
|
||||
87
clients/ubuntu/troubleshoot-install.sh
Executable file
87
clients/ubuntu/troubleshoot-install.sh
Executable file
@@ -0,0 +1,87 @@
|
||||
#!/bin/bash
|
||||
# Troubleshooting script for Oculog client installation
|
||||
|
||||
echo "Checking Oculog client installation..."
|
||||
echo ""
|
||||
|
||||
# Check if client script exists
|
||||
if [ -f /opt/oculog/client.py ]; then
|
||||
echo "✓ Client script found at /opt/oculog/client.py"
|
||||
else
|
||||
echo "✗ Client script NOT found at /opt/oculog/client.py"
|
||||
echo " You may need to download it manually or re-run the installer"
|
||||
fi
|
||||
|
||||
# Check if config exists
|
||||
if [ -f /etc/oculog/client.conf ]; then
|
||||
echo "✓ Configuration file found at /etc/oculog/client.conf"
|
||||
echo " Current config:"
|
||||
cat /etc/oculog/client.conf | sed 's/^/ /'
|
||||
else
|
||||
echo "✗ Configuration file NOT found at /etc/oculog/client.conf"
|
||||
fi
|
||||
|
||||
# Check if service file exists
|
||||
if [ -f /etc/systemd/system/oculog-client.service ]; then
|
||||
echo "✓ Systemd service file found"
|
||||
echo " Service file contents:"
|
||||
cat /etc/systemd/system/oculog-client.service | sed 's/^/ /'
|
||||
else
|
||||
echo "✗ Systemd service file NOT found"
|
||||
echo ""
|
||||
echo "Creating systemd service file..."
|
||||
|
||||
# Determine Python path
|
||||
if [ -f /opt/oculog/venv/bin/python3 ]; then
|
||||
PYTHON_BIN="/opt/oculog/venv/bin/python3"
|
||||
echo " Using virtual environment Python: $PYTHON_BIN"
|
||||
else
|
||||
PYTHON_BIN="/usr/bin/python3"
|
||||
echo " Using system Python: $PYTHON_BIN"
|
||||
fi
|
||||
|
||||
# Create service file
|
||||
cat > /etc/systemd/system/oculog-client.service << EOF
|
||||
[Unit]
|
||||
Description=Oculog Client Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=${PYTHON_BIN} /opt/oculog/client.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
echo "✓ Service file created"
|
||||
systemctl daemon-reload
|
||||
echo "✓ Systemd daemon reloaded"
|
||||
fi
|
||||
|
||||
# Check if symlink exists
|
||||
if [ -L /usr/local/bin/oculog-client ]; then
|
||||
echo "✓ Symlink found at /usr/local/bin/oculog-client"
|
||||
else
|
||||
echo "✗ Symlink NOT found"
|
||||
if [ -f /opt/oculog/client.py ]; then
|
||||
echo " Creating symlink..."
|
||||
ln -sf /opt/oculog/client.py /usr/local/bin/oculog-client
|
||||
echo "✓ Symlink created"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Installation check complete!"
|
||||
echo ""
|
||||
echo "To start the service:"
|
||||
echo " sudo systemctl start oculog-client"
|
||||
echo " sudo systemctl enable oculog-client"
|
||||
echo ""
|
||||
echo "To check status:"
|
||||
echo " sudo systemctl status oculog-client"
|
||||
41
clients/ubuntu/uninstall.sh
Executable file
41
clients/ubuntu/uninstall.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${YELLOW}Oculog Client Uninstallation${NC}"
|
||||
echo "===================================="
|
||||
echo ""
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}Please run as root (use sudo)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Step 1: Stopping and disabling service...${NC}"
|
||||
systemctl stop oculog-client.service 2>/dev/null || true
|
||||
systemctl disable oculog-client.service 2>/dev/null || true
|
||||
|
||||
echo -e "${GREEN}Step 2: Removing systemd service file...${NC}"
|
||||
rm -f /etc/systemd/system/oculog-client.service
|
||||
systemctl daemon-reload
|
||||
|
||||
echo -e "${GREEN}Step 3: Removing symlink...${NC}"
|
||||
rm -f /usr/local/bin/oculog-client
|
||||
|
||||
echo -e "${GREEN}Step 4: Removing installation directory...${NC}"
|
||||
rm -rf /opt/oculog
|
||||
|
||||
echo -e "${YELLOW}Note: Configuration files and logs are preserved:${NC}"
|
||||
echo " - /etc/oculog/client.conf"
|
||||
echo " - /var/log/oculog/client.log"
|
||||
echo ""
|
||||
echo -e "${GREEN}Uninstallation completed!${NC}"
|
||||
echo ""
|
||||
|
||||
146
clients/ubuntu/update-client.sh
Executable file
146
clients/ubuntu/update-client.sh
Executable file
@@ -0,0 +1,146 @@
|
||||
#!/bin/bash
|
||||
# Oculog Client Update Script
|
||||
# Updates the Oculog client with success/failure reporting for each step
|
||||
#
|
||||
# Usage:
|
||||
# sudo bash update-client.sh [OCULOG_SERVER_URL]
|
||||
# Example: sudo bash update-client.sh http://YOUR_SERVER_IP:3001
|
||||
#
|
||||
# Or set environment variable:
|
||||
# export OCULOG_SERVER=http://YOUR_SERVER_IP:3001
|
||||
# sudo bash update-client.sh
|
||||
|
||||
set -e # Exit on error (we'll handle errors manually)
|
||||
|
||||
# Configuration - use argument, environment variable, or default
|
||||
if [ -n "$1" ]; then
|
||||
OCULOG_SERVER="$1"
|
||||
elif [ -n "$OCULOG_SERVER" ]; then
|
||||
OCULOG_SERVER="$OCULOG_SERVER"
|
||||
else
|
||||
echo "Error: Oculog server URL not specified"
|
||||
echo ""
|
||||
echo "Usage: sudo bash update-client.sh [OCULOG_SERVER_URL]"
|
||||
echo "Example: sudo bash update-client.sh http://YOUR_SERVER_IP:3001"
|
||||
echo ""
|
||||
echo "Or set environment variable:"
|
||||
echo " export OCULOG_SERVER=http://YOUR_SERVER_IP:3001"
|
||||
echo " sudo bash update-client.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Oculog Client Update Script ==="
|
||||
echo "Server: $OCULOG_SERVER"
|
||||
echo ""
|
||||
|
||||
# Function to check command success
|
||||
check_success() {
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ Success"
|
||||
return 0
|
||||
else
|
||||
echo "✗ Failed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 1. Stop the Oculog client service
|
||||
echo -n "Step 1: Stopping oculog-client service... "
|
||||
if sudo systemctl stop oculog-client.service 2>/dev/null; then
|
||||
echo "✓ Success"
|
||||
else
|
||||
echo "✗ Failed (service may not be running)"
|
||||
fi
|
||||
|
||||
# 2. Download the updated client script
|
||||
echo -n "Step 2: Downloading updated client script... "
|
||||
if sudo curl -s "$OCULOG_SERVER/api/client-script" | sudo tee /opt/oculog/client.py.new > /dev/null 2>&1; then
|
||||
if [ -f /opt/oculog/client.py.new ] && [ -s /opt/oculog/client.py.new ]; then
|
||||
echo "✓ Success"
|
||||
else
|
||||
echo "✗ Failed - File not created or empty"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "✗ Failed - Download error"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. Backup the current client script
|
||||
echo -n "Step 3: Backing up current client script... "
|
||||
if [ -f /opt/oculog/client.py ]; then
|
||||
if sudo cp /opt/oculog/client.py /opt/oculog/client.py.backup 2>/dev/null; then
|
||||
echo "✓ Success"
|
||||
else
|
||||
echo "✗ Failed"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "⚠ Skipped - No existing client.py found"
|
||||
fi
|
||||
|
||||
# 4. Replace the client script
|
||||
echo -n "Step 4: Replacing client script... "
|
||||
if sudo mv /opt/oculog/client.py.new /opt/oculog/client.py 2>/dev/null; then
|
||||
echo "✓ Success"
|
||||
else
|
||||
echo "✗ Failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Verify version was injected
|
||||
echo -n "Step 5: Verifying version injection... "
|
||||
if sudo grep -q 'CLIENT_VERSION_BUILD_TIMESTAMP = "[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}-[0-9]\{2\}-[0-9]\{2\}"' /opt/oculog/client.py 2>/dev/null; then
|
||||
VERSION=$(sudo grep 'CLIENT_VERSION_BUILD_TIMESTAMP = "' /opt/oculog/client.py | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||
echo "✓ Success (Version: $VERSION)"
|
||||
else
|
||||
echo "⚠ Warning - Version may not have been injected (will use file modification time)"
|
||||
fi
|
||||
|
||||
# 6. Ensure the script is executable
|
||||
echo -n "Step 6: Setting executable permissions... "
|
||||
if sudo chmod +x /opt/oculog/client.py 2>/dev/null; then
|
||||
echo "✓ Success"
|
||||
else
|
||||
echo "✗ Failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 7. Restart the service
|
||||
echo -n "Step 7: Starting oculog-client service... "
|
||||
if sudo systemctl start oculog-client.service 2>/dev/null; then
|
||||
echo "✓ Success"
|
||||
else
|
||||
echo "✗ Failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 8. Wait a moment for service to start
|
||||
sleep 2
|
||||
|
||||
# 9. Verify the service is running
|
||||
echo -n "Step 8: Verifying service status... "
|
||||
if sudo systemctl is-active --quiet oculog-client.service 2>/dev/null; then
|
||||
echo "✓ Success - Service is running"
|
||||
else
|
||||
echo "✗ Failed - Service is not running"
|
||||
echo ""
|
||||
echo "Checking service status:"
|
||||
sudo systemctl status oculog-client.service --no-pager -l || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Update Complete ==="
|
||||
echo ""
|
||||
echo "Note: The client version in the web UI will update after the client sends"
|
||||
echo " its next metrics update (usually within 30 seconds)."
|
||||
echo ""
|
||||
echo "To view logs, run:"
|
||||
echo " sudo journalctl -u oculog-client.service -f"
|
||||
echo ""
|
||||
echo "To check service status, run:"
|
||||
echo " sudo systemctl status oculog-client.service"
|
||||
echo ""
|
||||
echo "To verify the client version immediately, check logs for:"
|
||||
echo " sudo journalctl -u oculog-client.service -n 50 | grep -i version"
|
||||
75
docker-compose.yml
Normal file
75
docker-compose.yml
Normal file
@@ -0,0 +1,75 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: oculog-postgres
|
||||
environment:
|
||||
- POSTGRES_DB=oculog
|
||||
- POSTGRES_USER=oculog
|
||||
- POSTGRES_PASSWORD=oculog_password
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./server/backend/db/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
ports:
|
||||
- "5432:5432"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- oculog-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U oculog"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./server/backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: oculog-backend
|
||||
ports:
|
||||
- "3001:3001"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- PORT=3001
|
||||
- CORS_ORIGIN=http://localhost:3000
|
||||
- DB_HOST=postgres
|
||||
- DB_PORT=5432
|
||||
- DB_NAME=oculog
|
||||
- DB_USER=oculog
|
||||
- DB_PASSWORD=oculog_password
|
||||
volumes:
|
||||
- ./server/backend:/app
|
||||
- ./clients:/app/clients
|
||||
- /app/node_modules
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- oculog-network
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./server/frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: oculog-frontend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- REACT_APP_API_URL=http://localhost:3001
|
||||
volumes:
|
||||
- ./server/frontend:/app
|
||||
- /app/node_modules
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- oculog-network
|
||||
|
||||
networks:
|
||||
oculog-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
7
server/backend/.dockerignore
Normal file
7
server/backend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
|
||||
19
server/backend/Dockerfile
Normal file
19
server/backend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
# Start the application
|
||||
CMD ["npm", "start"]
|
||||
|
||||
125
server/backend/db/init.sql
Normal file
125
server/backend/db/init.sql
Normal file
@@ -0,0 +1,125 @@
|
||||
-- Create metrics table
|
||||
CREATE TABLE IF NOT EXISTS metrics (
|
||||
id SERIAL PRIMARY KEY,
|
||||
server_id VARCHAR(255) NOT NULL,
|
||||
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
cpu_usage DECIMAL(5,2),
|
||||
cpu_cores INTEGER,
|
||||
memory_total DECIMAL(10,2),
|
||||
memory_used DECIMAL(10,2),
|
||||
memory_available DECIMAL(10,2),
|
||||
memory_percent DECIMAL(5,2),
|
||||
disk_total DECIMAL(10,2),
|
||||
disk_used DECIMAL(10,2),
|
||||
disk_free DECIMAL(10,2),
|
||||
disk_percent DECIMAL(5,2),
|
||||
disks JSONB,
|
||||
network_rx DECIMAL(10,2),
|
||||
network_tx DECIMAL(10,2),
|
||||
network_rx_total DECIMAL(10,2),
|
||||
network_tx_total DECIMAL(10,2),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create indexes for better query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_metrics_server_id ON metrics(server_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON metrics(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_metrics_server_timestamp ON metrics(server_id, timestamp DESC);
|
||||
|
||||
-- Migration: Add disks column if it doesn't exist (for existing databases)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'metrics' AND column_name = 'disks'
|
||||
) THEN
|
||||
ALTER TABLE metrics ADD COLUMN disks JSONB;
|
||||
CREATE INDEX IF NOT EXISTS idx_metrics_disks ON metrics USING GIN (disks);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Create index for disks column (will be created if column exists)
|
||||
CREATE INDEX IF NOT EXISTS idx_metrics_disks ON metrics USING GIN (disks);
|
||||
|
||||
-- Migration: Add load average columns if they don't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'metrics' AND column_name = 'load_avg_1min'
|
||||
) THEN
|
||||
ALTER TABLE metrics ADD COLUMN load_avg_1min DECIMAL(5,2);
|
||||
ALTER TABLE metrics ADD COLUMN load_avg_5min DECIMAL(5,2);
|
||||
ALTER TABLE metrics ADD COLUMN load_avg_15min DECIMAL(5,2);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Migration: Add swap columns if they don't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'metrics' AND column_name = 'swap_total'
|
||||
) THEN
|
||||
ALTER TABLE metrics ADD COLUMN swap_total DECIMAL(10,2);
|
||||
ALTER TABLE metrics ADD COLUMN swap_used DECIMAL(10,2);
|
||||
ALTER TABLE metrics ADD COLUMN swap_free DECIMAL(10,2);
|
||||
ALTER TABLE metrics ADD COLUMN swap_percent DECIMAL(5,2);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Migration: Add process_count and uptime columns if they don't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'metrics' AND column_name = 'process_count'
|
||||
) THEN
|
||||
ALTER TABLE metrics ADD COLUMN process_count INTEGER;
|
||||
ALTER TABLE metrics ADD COLUMN uptime_seconds DECIMAL(10,2);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Create servers table to track server metadata
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
server_id VARCHAR(255) PRIMARY KEY,
|
||||
hostname VARCHAR(255),
|
||||
first_seen TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
last_seen TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
total_metrics_count INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
-- Create function to update server last_seen and metrics count
|
||||
CREATE OR REPLACE FUNCTION update_server_stats()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO servers (server_id, hostname, first_seen, last_seen, total_metrics_count)
|
||||
VALUES (NEW.server_id, NEW.server_id, NOW(), NOW(), 1)
|
||||
ON CONFLICT (server_id) DO UPDATE
|
||||
SET last_seen = NOW(),
|
||||
total_metrics_count = servers.total_metrics_count + 1;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger to automatically update server stats
|
||||
DROP TRIGGER IF EXISTS trigger_update_server_stats ON metrics;
|
||||
CREATE TRIGGER trigger_update_server_stats
|
||||
AFTER INSERT ON metrics
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_server_stats();
|
||||
|
||||
-- Create API keys table for client authentication
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id SERIAL PRIMARY KEY,
|
||||
key_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
server_id VARCHAR(255),
|
||||
name VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
last_used TIMESTAMP WITH TIME ZONE,
|
||||
is_active BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_server_id ON api_keys(server_id);
|
||||
|
||||
8
server/backend/db/migration_add_disks.sql
Normal file
8
server/backend/db/migration_add_disks.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Migration: Add disks column to metrics table
|
||||
-- This allows storing multiple disk metrics as JSON
|
||||
|
||||
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS disks JSONB;
|
||||
|
||||
-- Create index on disks column for better query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_metrics_disks ON metrics USING GIN (disks);
|
||||
|
||||
17
server/backend/db/migration_add_server_info.sql
Normal file
17
server/backend/db/migration_add_server_info.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Migration: Add server_info column to servers table
|
||||
-- This column stores server metadata: OS info, live status, top processes, containers, IP info
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'servers' AND column_name = 'server_info'
|
||||
) THEN
|
||||
ALTER TABLE servers ADD COLUMN server_info JSONB;
|
||||
CREATE INDEX IF NOT EXISTS idx_servers_server_info ON servers USING GIN (server_info);
|
||||
RAISE NOTICE 'server_info column added to servers table';
|
||||
ELSE
|
||||
RAISE NOTICE 'server_info column already exists';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
44
server/backend/db/migration_add_synthetic_monitors.sql
Normal file
44
server/backend/db/migration_add_synthetic_monitors.sql
Normal file
@@ -0,0 +1,44 @@
|
||||
-- Create synthetic_monitors table
|
||||
CREATE TABLE IF NOT EXISTS synthetic_monitors (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(50) NOT NULL CHECK (type IN ('http_status', 'ping', 'port_check')),
|
||||
target VARCHAR(500) NOT NULL,
|
||||
expected_status INTEGER,
|
||||
port INTEGER,
|
||||
interval INTEGER NOT NULL DEFAULT 60,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create monitor_results table
|
||||
CREATE TABLE IF NOT EXISTS monitor_results (
|
||||
id SERIAL PRIMARY KEY,
|
||||
monitor_id INTEGER NOT NULL REFERENCES synthetic_monitors(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL CHECK (status IN ('success', 'failed')),
|
||||
response_time INTEGER,
|
||||
message TEXT,
|
||||
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create indexes for better query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_monitor_results_monitor_id ON monitor_results(monitor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_monitor_results_timestamp ON monitor_results(timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_synthetic_monitors_enabled ON synthetic_monitors(enabled) WHERE enabled = TRUE;
|
||||
|
||||
-- Create function to update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger to automatically update updated_at
|
||||
DROP TRIGGER IF EXISTS trigger_update_synthetic_monitors_updated_at ON synthetic_monitors;
|
||||
CREATE TRIGGER trigger_update_synthetic_monitors_updated_at
|
||||
BEFORE UPDATE ON synthetic_monitors
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
1500
server/backend/package-lock.json
generated
Normal file
1500
server/backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
server/backend/package.json
Normal file
26
server/backend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "oculog-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend API for Oculog metrics observability platform",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"migrate": "node src/migrate-cli.js"
|
||||
},
|
||||
"keywords": ["metrics", "observability", "monitoring"],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"body-parser": "^1.20.2",
|
||||
"pg": "^8.11.3",
|
||||
"axios": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
271
server/backend/src/alertEvaluator.js
Normal file
271
server/backend/src/alertEvaluator.js
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Alert Evaluator
|
||||
* Evaluates alert policies against current server metrics and synthetic monitor results
|
||||
*/
|
||||
|
||||
const db = require('./db');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Evaluate server metric alerts
|
||||
*/
|
||||
async function evaluateServerMetricAlerts() {
|
||||
try {
|
||||
const policies = await db.getEnabledAlertPolicies();
|
||||
const serverMetricPolicies = policies.filter(p => p.type === 'server_metric');
|
||||
|
||||
if (serverMetricPolicies.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get latest metrics for all servers
|
||||
const latestMetrics = await db.getLatestMetrics();
|
||||
const servers = await db.getServers();
|
||||
|
||||
for (const policy of serverMetricPolicies) {
|
||||
const serversToCheck = policy.server_id
|
||||
? servers.filter(s => s.server_id === policy.server_id)
|
||||
: servers;
|
||||
|
||||
for (const server of serversToCheck) {
|
||||
const metrics = latestMetrics[server.server_id];
|
||||
if (!metrics) {
|
||||
// Check for server_not_reporting
|
||||
if (policy.metric_type === 'server_not_reporting') {
|
||||
const lastSeen = new Date(server.last_seen);
|
||||
const now = new Date();
|
||||
const minutesSinceLastSeen = (now - lastSeen) / (1000 * 60);
|
||||
|
||||
if (minutesSinceLastSeen >= policy.threshold) {
|
||||
await triggerAlert(policy, server.server_id,
|
||||
`Server ${server.server_id} has not reported metrics for ${Math.round(minutesSinceLastSeen)} minutes`);
|
||||
} else {
|
||||
await resolveAlertIfExists(policy, server.server_id);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Evaluate based on metric type
|
||||
let shouldAlert = false;
|
||||
let message = '';
|
||||
|
||||
switch (policy.metric_type) {
|
||||
case 'cpu_high':
|
||||
const cpuUsage = metrics.cpu?.usage || 0;
|
||||
shouldAlert = cpuUsage >= policy.threshold;
|
||||
if (shouldAlert) {
|
||||
message = `CPU usage is ${cpuUsage.toFixed(1)}% (threshold: ${policy.threshold}%)`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ram_high':
|
||||
const memPercent = metrics.memory?.percent || 0;
|
||||
shouldAlert = memPercent >= policy.threshold;
|
||||
if (shouldAlert) {
|
||||
message = `RAM usage is ${memPercent.toFixed(1)}% (threshold: ${policy.threshold}%)`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'disk_used':
|
||||
const diskPercent = metrics.disk?.percent || 0;
|
||||
shouldAlert = diskPercent >= policy.threshold;
|
||||
if (shouldAlert) {
|
||||
message = `Disk usage is ${diskPercent.toFixed(1)}% (threshold: ${policy.threshold}%)`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'server_not_reporting':
|
||||
const lastSeen = new Date(server.last_seen);
|
||||
const now = new Date();
|
||||
const minutesSinceLastSeen = (now - lastSeen) / (1000 * 60);
|
||||
shouldAlert = minutesSinceLastSeen >= policy.threshold;
|
||||
if (shouldAlert) {
|
||||
message = `Server has not reported metrics for ${Math.round(minutesSinceLastSeen)} minutes`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'client_out_of_date':
|
||||
const clientVersion = await db.getClientVersion(server.server_id);
|
||||
if (clientVersion) {
|
||||
// Get latest client version (same logic as /api/servers/:serverId/info)
|
||||
let latestVersion = null;
|
||||
try {
|
||||
const possiblePaths = [
|
||||
path.join(__dirname, '../../../../clients/ubuntu/client.py'),
|
||||
path.join(__dirname, '../../../clients/ubuntu/client.py'),
|
||||
path.join(process.cwd(), 'clients/ubuntu/client.py'),
|
||||
'/app/clients/ubuntu/client.py'
|
||||
];
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
try {
|
||||
const stats = await fs.stat(p);
|
||||
const mtime = new Date(stats.mtime);
|
||||
latestVersion = mtime.toISOString().slice(0, 16).replace('T', '-').replace(':', '-');
|
||||
break;
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (latestVersion && clientVersion < latestVersion) {
|
||||
shouldAlert = true;
|
||||
message = `Client version ${clientVersion} is out of date (latest: ${latestVersion})`;
|
||||
}
|
||||
} catch (e) {
|
||||
// If we can't check, don't alert
|
||||
shouldAlert = false;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (shouldAlert) {
|
||||
await triggerAlert(policy, server.server_id, message);
|
||||
} else {
|
||||
await resolveAlertIfExists(policy, server.server_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Alert Evaluator] Error evaluating server metric alerts:', {
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate synthetic monitor alerts
|
||||
*/
|
||||
async function evaluateSyntheticMonitorAlerts() {
|
||||
try {
|
||||
const policies = await db.getEnabledAlertPolicies();
|
||||
const monitorPolicies = policies.filter(p => p.type === 'synthetic_monitor');
|
||||
|
||||
if (monitorPolicies.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all monitors with their last results
|
||||
const monitors = await db.getSyntheticMonitors();
|
||||
|
||||
for (const policy of monitorPolicies) {
|
||||
const monitorsToCheck = policy.monitor_id
|
||||
? monitors.filter(m => m.id === policy.monitor_id)
|
||||
: monitors;
|
||||
|
||||
for (const monitor of monitorsToCheck) {
|
||||
if (!monitor.last_result) {
|
||||
continue; // No results yet
|
||||
}
|
||||
|
||||
// Check if monitor failed
|
||||
if (monitor.last_result.status === 'failed') {
|
||||
await triggerAlert(policy, `Monitor ${monitor.id}`,
|
||||
`Synthetic monitor "${monitor.name}" failed: ${monitor.last_result.message || 'Check failed'}`);
|
||||
} else {
|
||||
// Monitor is healthy, resolve any active alerts
|
||||
await resolveAlertIfExists(policy, `Monitor ${monitor.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Alert Evaluator] Error evaluating synthetic monitor alerts:', {
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an alert if one doesn't already exist
|
||||
*/
|
||||
async function triggerAlert(policy, target, message) {
|
||||
try {
|
||||
// Check if alert already exists
|
||||
const existingAlert = await db.getActiveAlert(policy.id, target);
|
||||
|
||||
if (existingAlert) {
|
||||
// Alert already exists, don't create duplicate
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new alert
|
||||
await db.createAlert({
|
||||
policy_id: policy.id,
|
||||
policy_name: policy.name,
|
||||
target: target,
|
||||
message: message,
|
||||
status: 'active'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Alert Evaluator] Error triggering alert:', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
policyId: policy.id,
|
||||
policyName: policy.name,
|
||||
target,
|
||||
alertMessage: message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve alert if it exists
|
||||
*/
|
||||
async function resolveAlertIfExists(policy, target) {
|
||||
try {
|
||||
const existingAlert = await db.getActiveAlert(policy.id, target);
|
||||
|
||||
if (existingAlert) {
|
||||
await db.resolveAlert({
|
||||
alert_id: existingAlert.id
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Alert Evaluator] Error resolving alert:', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
policyId: policy.id,
|
||||
policyName: policy.name,
|
||||
target,
|
||||
alertId: existingAlert?.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run alert evaluation
|
||||
*/
|
||||
async function evaluateAlerts() {
|
||||
try {
|
||||
await evaluateServerMetricAlerts();
|
||||
await evaluateSyntheticMonitorAlerts();
|
||||
} catch (error) {
|
||||
console.error('[Alert Evaluator] Error in alert evaluation:', {
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the alert evaluator
|
||||
*/
|
||||
function startAlertEvaluator() {
|
||||
// Run evaluation immediately on startup
|
||||
evaluateAlerts();
|
||||
|
||||
// Then run evaluation every 60 seconds
|
||||
setInterval(evaluateAlerts, 60000);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
evaluateAlerts,
|
||||
evaluateServerMetricAlerts,
|
||||
evaluateSyntheticMonitorAlerts,
|
||||
startAlertEvaluator
|
||||
};
|
||||
2016
server/backend/src/db.js
Normal file
2016
server/backend/src/db.js
Normal file
File diff suppressed because it is too large
Load Diff
1067
server/backend/src/index.js
Normal file
1067
server/backend/src/index.js
Normal file
File diff suppressed because it is too large
Load Diff
27
server/backend/src/middleware/auth.js
Normal file
27
server/backend/src/middleware/auth.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const db = require('../db');
|
||||
|
||||
/**
|
||||
* Middleware to authenticate API key
|
||||
*/
|
||||
async function authenticateApiKey(req, res, next) {
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({ error: 'API key required' });
|
||||
}
|
||||
|
||||
const keyInfo = await db.validateApiKey(apiKey);
|
||||
|
||||
if (!keyInfo) {
|
||||
return res.status(403).json({ error: 'Invalid or inactive API key' });
|
||||
}
|
||||
|
||||
// Attach key info to request for use in routes
|
||||
req.apiKeyInfo = keyInfo;
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authenticateApiKey
|
||||
};
|
||||
|
||||
28
server/backend/src/migrate-cli.js
Normal file
28
server/backend/src/migrate-cli.js
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Standalone migration runner
|
||||
* Run this script manually to execute database migrations
|
||||
*
|
||||
* Usage:
|
||||
* node src/migrate-cli.js
|
||||
* npm run migrate
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const { runMigrations } = require('./migrate');
|
||||
|
||||
async function main() {
|
||||
console.log('Running database migrations manually...\n');
|
||||
|
||||
try {
|
||||
await runMigrations();
|
||||
console.log('\n✓ Migrations completed successfully');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('\n✗ Migration failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
419
server/backend/src/migrate.js
Normal file
419
server/backend/src/migrate.js
Normal file
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* Database migration runner
|
||||
* Runs migrations on startup to ensure database schema is up to date
|
||||
*/
|
||||
|
||||
const db = require('./db');
|
||||
|
||||
async function runMigrations() {
|
||||
try {
|
||||
// Check if disks column exists
|
||||
const checkColumnQuery = `
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'metrics' AND column_name = 'disks'
|
||||
`;
|
||||
|
||||
const result = await db.pool.query(checkColumnQuery);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
try {
|
||||
// Add the disks column
|
||||
await db.pool.query(`
|
||||
ALTER TABLE metrics ADD COLUMN disks JSONB
|
||||
`);
|
||||
} catch (error) {
|
||||
// Column might have been added between check and alter
|
||||
if (error.code !== '42701') { // duplicate column error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Create index if it doesn't exist
|
||||
try {
|
||||
await db.pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_metrics_disks ON metrics USING GIN (disks)
|
||||
`);
|
||||
} catch (error) {
|
||||
console.error('[Migration] Warning: Could not create disks index:', {
|
||||
message: error.message,
|
||||
code: error.code
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if server_info column exists
|
||||
const checkServerInfoQuery = `
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'servers' AND column_name = 'server_info'
|
||||
`;
|
||||
|
||||
const serverInfoResult = await db.pool.query(checkServerInfoQuery);
|
||||
|
||||
if (serverInfoResult.rows.length === 0) {
|
||||
try {
|
||||
// Add the server_info column
|
||||
await db.pool.query(`
|
||||
ALTER TABLE servers ADD COLUMN server_info JSONB
|
||||
`);
|
||||
} catch (error) {
|
||||
// Column might have been added between check and alter
|
||||
if (error.code !== '42701') { // duplicate column error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Create index if it doesn't exist
|
||||
try {
|
||||
await db.pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_servers_server_info ON servers USING GIN (server_info)
|
||||
`);
|
||||
} catch (error) {
|
||||
console.error('[Migration] Warning: Could not create server_info index:', {
|
||||
message: error.message,
|
||||
code: error.code
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if client_version column exists
|
||||
const checkClientVersionQuery = `
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'servers' AND column_name = 'client_version'
|
||||
`;
|
||||
|
||||
const clientVersionResult = await db.pool.query(checkClientVersionQuery);
|
||||
|
||||
if (clientVersionResult.rows.length === 0) {
|
||||
try {
|
||||
// Add the client_version column
|
||||
await db.pool.query(`
|
||||
ALTER TABLE servers ADD COLUMN client_version VARCHAR(50)
|
||||
`);
|
||||
} catch (error) {
|
||||
// Column might have been added between check and alter
|
||||
if (error.code !== '42701') { // duplicate column error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check and add swap columns if they don't exist
|
||||
const checkSwapQuery = `
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'metrics' AND column_name = 'swap_total'
|
||||
`;
|
||||
|
||||
const swapResult = await db.pool.query(checkSwapQuery);
|
||||
|
||||
if (swapResult.rows.length === 0) {
|
||||
try {
|
||||
await db.pool.query(`
|
||||
ALTER TABLE metrics ADD COLUMN swap_total DECIMAL(10,2);
|
||||
ALTER TABLE metrics ADD COLUMN swap_used DECIMAL(10,2);
|
||||
ALTER TABLE metrics ADD COLUMN swap_free DECIMAL(10,2);
|
||||
ALTER TABLE metrics ADD COLUMN swap_percent DECIMAL(5,2);
|
||||
`);
|
||||
} catch (error) {
|
||||
if (error.code !== '42701') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check and add process_count and uptime_seconds columns if they don't exist
|
||||
const checkProcessCountQuery = `
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'metrics' AND column_name = 'process_count'
|
||||
`;
|
||||
|
||||
const processCountResult = await db.pool.query(checkProcessCountQuery);
|
||||
|
||||
if (processCountResult.rows.length === 0) {
|
||||
try {
|
||||
await db.pool.query(`
|
||||
ALTER TABLE metrics ADD COLUMN process_count INTEGER;
|
||||
ALTER TABLE metrics ADD COLUMN uptime_seconds DECIMAL(10,2);
|
||||
`);
|
||||
} catch (error) {
|
||||
if (error.code !== '42701') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check and add load_avg columns if they don't exist
|
||||
const checkLoadAvgQuery = `
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'metrics' AND column_name = 'load_avg_1min'
|
||||
`;
|
||||
|
||||
const loadAvgResult = await db.pool.query(checkLoadAvgQuery);
|
||||
|
||||
if (loadAvgResult.rows.length === 0) {
|
||||
try {
|
||||
await db.pool.query(`
|
||||
ALTER TABLE metrics ADD COLUMN load_avg_1min DECIMAL(5,2);
|
||||
ALTER TABLE metrics ADD COLUMN load_avg_5min DECIMAL(5,2);
|
||||
ALTER TABLE metrics ADD COLUMN load_avg_15min DECIMAL(5,2);
|
||||
`);
|
||||
} catch (error) {
|
||||
if (error.code !== '42701') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if synthetic_monitors table exists
|
||||
const checkSyntheticMonitorsQuery = `
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_name = 'synthetic_monitors'
|
||||
`;
|
||||
|
||||
const syntheticMonitorsResult = await db.pool.query(checkSyntheticMonitorsQuery);
|
||||
|
||||
if (syntheticMonitorsResult.rows.length === 0) {
|
||||
try {
|
||||
// Create synthetic_monitors table
|
||||
await db.pool.query(`
|
||||
CREATE TABLE synthetic_monitors (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(50) NOT NULL CHECK (type IN ('http_status', 'ping', 'port_check')),
|
||||
target VARCHAR(500) NOT NULL,
|
||||
expected_status INTEGER,
|
||||
port INTEGER,
|
||||
interval INTEGER NOT NULL DEFAULT 60,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
// Create monitor_results table
|
||||
await db.pool.query(`
|
||||
CREATE TABLE monitor_results (
|
||||
id SERIAL PRIMARY KEY,
|
||||
monitor_id INTEGER NOT NULL REFERENCES synthetic_monitors(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL CHECK (status IN ('success', 'failed')),
|
||||
response_time INTEGER,
|
||||
message TEXT,
|
||||
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
// Create indexes
|
||||
await db.pool.query(`
|
||||
CREATE INDEX idx_monitor_results_monitor_id ON monitor_results(monitor_id);
|
||||
CREATE INDEX idx_monitor_results_timestamp ON monitor_results(timestamp DESC);
|
||||
CREATE INDEX idx_synthetic_monitors_enabled ON synthetic_monitors(enabled) WHERE enabled = TRUE
|
||||
`);
|
||||
|
||||
// Create trigger function for updated_at
|
||||
await db.pool.query(`
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
`);
|
||||
|
||||
// Create trigger
|
||||
await db.pool.query(`
|
||||
CREATE TRIGGER trigger_update_synthetic_monitors_updated_at
|
||||
BEFORE UPDATE ON synthetic_monitors
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column()
|
||||
`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Migration] Error creating synthetic monitors tables:', {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
stack: error.stack
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if alert_policies table exists
|
||||
const checkAlertPoliciesQuery = `
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_name = 'alert_policies'
|
||||
`;
|
||||
|
||||
const alertPoliciesResult = await db.pool.query(checkAlertPoliciesQuery);
|
||||
|
||||
if (alertPoliciesResult.rows.length === 0) {
|
||||
try {
|
||||
// Create alert_policies table
|
||||
await db.pool.query(`
|
||||
CREATE TABLE alert_policies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(50) NOT NULL CHECK (type IN ('server_metric', 'synthetic_monitor')),
|
||||
metric_type VARCHAR(50) CHECK (metric_type IN ('cpu_high', 'ram_high', 'disk_used', 'server_not_reporting', 'client_out_of_date')),
|
||||
monitor_id INTEGER REFERENCES synthetic_monitors(id) ON DELETE CASCADE,
|
||||
threshold DECIMAL(10,2),
|
||||
server_id VARCHAR(255),
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
// Create alerts table
|
||||
await db.pool.query(`
|
||||
CREATE TABLE alerts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
policy_id INTEGER NOT NULL REFERENCES alert_policies(id) ON DELETE CASCADE,
|
||||
policy_name VARCHAR(255) NOT NULL,
|
||||
target VARCHAR(255),
|
||||
message TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL CHECK (status IN ('active', 'resolved')) DEFAULT 'active',
|
||||
triggered_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
resolved_at TIMESTAMP WITH TIME ZONE
|
||||
)
|
||||
`);
|
||||
|
||||
// Create indexes
|
||||
await db.pool.query(`
|
||||
CREATE INDEX idx_alerts_policy_id ON alerts(policy_id);
|
||||
CREATE INDEX idx_alerts_status ON alerts(status);
|
||||
CREATE INDEX idx_alerts_triggered_at ON alerts(triggered_at DESC);
|
||||
CREATE INDEX idx_alert_policies_enabled ON alert_policies(enabled) WHERE enabled = TRUE;
|
||||
CREATE INDEX idx_alert_policies_type ON alert_policies(type)
|
||||
`);
|
||||
|
||||
// Create trigger function for updated_at (if not exists)
|
||||
await db.pool.query(`
|
||||
CREATE OR REPLACE FUNCTION update_alert_policy_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
`);
|
||||
|
||||
// Create trigger
|
||||
await db.pool.query(`
|
||||
CREATE TRIGGER trigger_update_alert_policies_updated_at
|
||||
BEFORE UPDATE ON alert_policies
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_alert_policy_updated_at()
|
||||
`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Migration] Error creating alert policies tables:', {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
stack: error.stack
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if vulnerabilities table exists
|
||||
const checkVulnerabilitiesQuery = `
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_name = 'vulnerabilities'
|
||||
`;
|
||||
|
||||
const vulnerabilitiesResult = await db.pool.query(checkVulnerabilitiesQuery);
|
||||
|
||||
if (vulnerabilitiesResult.rows.length === 0) {
|
||||
try {
|
||||
// Create vulnerabilities table (vulnerability-centric)
|
||||
await db.pool.query(`
|
||||
CREATE TABLE vulnerabilities (
|
||||
id SERIAL PRIMARY KEY,
|
||||
cve_id VARCHAR(50) NOT NULL,
|
||||
package_name VARCHAR(255) NOT NULL,
|
||||
ecosystem VARCHAR(50) NOT NULL DEFAULT 'Ubuntu',
|
||||
severity VARCHAR(20),
|
||||
summary TEXT,
|
||||
description TEXT,
|
||||
fixed_version VARCHAR(255),
|
||||
affected_version_range TEXT,
|
||||
published_at TIMESTAMP WITH TIME ZONE,
|
||||
modified_at TIMESTAMP WITH TIME ZONE,
|
||||
first_detected TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
last_seen TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(cve_id, package_name, ecosystem)
|
||||
)
|
||||
`);
|
||||
|
||||
// Create server_vulnerabilities table (many-to-many)
|
||||
await db.pool.query(`
|
||||
CREATE TABLE server_vulnerabilities (
|
||||
id SERIAL PRIMARY KEY,
|
||||
vulnerability_id INTEGER NOT NULL REFERENCES vulnerabilities(id) ON DELETE CASCADE,
|
||||
server_id VARCHAR(255) NOT NULL,
|
||||
installed_version VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'fixed', 'ongoing')),
|
||||
first_detected TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
last_seen TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(vulnerability_id, server_id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Create vulnerability_cache table for OSV API results
|
||||
await db.pool.query(`
|
||||
CREATE TABLE vulnerability_cache (
|
||||
id SERIAL PRIMARY KEY,
|
||||
package_name VARCHAR(255) NOT NULL,
|
||||
package_version VARCHAR(255) NOT NULL,
|
||||
ecosystem VARCHAR(50) NOT NULL DEFAULT 'Ubuntu',
|
||||
vulnerabilities JSONB,
|
||||
cached_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
UNIQUE(package_name, package_version, ecosystem)
|
||||
)
|
||||
`);
|
||||
|
||||
// Create indexes
|
||||
await db.pool.query(`
|
||||
CREATE INDEX idx_vulnerabilities_cve_id ON vulnerabilities(cve_id);
|
||||
CREATE INDEX idx_vulnerabilities_package_name ON vulnerabilities(package_name);
|
||||
CREATE INDEX idx_vulnerabilities_severity ON vulnerabilities(severity);
|
||||
CREATE INDEX idx_vulnerabilities_last_seen ON vulnerabilities(last_seen DESC);
|
||||
CREATE INDEX idx_server_vulnerabilities_server_id ON server_vulnerabilities(server_id);
|
||||
CREATE INDEX idx_server_vulnerabilities_status ON server_vulnerabilities(status);
|
||||
CREATE INDEX idx_server_vulnerabilities_vulnerability_id ON server_vulnerabilities(vulnerability_id);
|
||||
CREATE INDEX idx_vulnerability_cache_expires_at ON vulnerability_cache(expires_at);
|
||||
CREATE INDEX idx_vulnerability_cache_lookup ON vulnerability_cache(package_name, package_version, ecosystem)
|
||||
`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Migration] Error creating vulnerabilities tables:', {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
stack: error.stack
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Migration] Error running migrations:', {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
stack: error.stack
|
||||
});
|
||||
// Don't throw - allow server to start even if migration fails
|
||||
// (column might already exist or there might be a permission issue)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { runMigrations };
|
||||
|
||||
282
server/backend/src/monitorRunner.js
Normal file
282
server/backend/src/monitorRunner.js
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Synthetic Monitor Runner
|
||||
* Periodically executes checks for enabled synthetic monitors
|
||||
*/
|
||||
|
||||
const db = require('./db');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { promisify } = require('util');
|
||||
const dns = require('dns');
|
||||
const net = require('net');
|
||||
|
||||
const dnsLookup = promisify(dns.lookup);
|
||||
|
||||
/**
|
||||
* Execute HTTP status check
|
||||
*/
|
||||
async function checkHttpStatus(monitor) {
|
||||
const startTime = Date.now();
|
||||
const url = monitor.target;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const client = url.startsWith('https') ? https : http;
|
||||
|
||||
const req = client.get(url, { timeout: 10000 }, (res) => {
|
||||
const responseTime = Date.now() - startTime;
|
||||
const statusCode = res.statusCode;
|
||||
const expectedStatus = monitor.expected_status || 200;
|
||||
|
||||
// Consume response to free up resources
|
||||
res.on('data', () => {});
|
||||
res.on('end', () => {
|
||||
if (statusCode === expectedStatus) {
|
||||
resolve({
|
||||
status: 'success',
|
||||
response_time: responseTime,
|
||||
message: `HTTP ${statusCode} (expected ${expectedStatus})`
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
status: 'failed',
|
||||
response_time: responseTime,
|
||||
message: `HTTP ${statusCode} (expected ${expectedStatus})`
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
const responseTime = Date.now() - startTime;
|
||||
resolve({
|
||||
status: 'failed',
|
||||
response_time: responseTime,
|
||||
message: `Connection error: ${error.message}`
|
||||
});
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
const responseTime = Date.now() - startTime;
|
||||
resolve({
|
||||
status: 'failed',
|
||||
response_time: responseTime,
|
||||
message: 'Request timeout'
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute ping check (ICMP-like check using DNS lookup + TCP connection)
|
||||
*/
|
||||
async function checkPing(monitor) {
|
||||
const startTime = Date.now();
|
||||
const hostname = monitor.target.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
||||
|
||||
try {
|
||||
// Resolve DNS
|
||||
const dnsStart = Date.now();
|
||||
await dnsLookup(hostname);
|
||||
const dnsTime = Date.now() - dnsStart;
|
||||
|
||||
// Try to establish a TCP connection to port 80 or 443
|
||||
const port = monitor.target.startsWith('https') ? 443 : 80;
|
||||
const connectStart = Date.now();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
socket.setTimeout(5000);
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.destroy();
|
||||
const responseTime = Date.now() - startTime;
|
||||
resolve({
|
||||
status: 'success',
|
||||
response_time: responseTime,
|
||||
message: `Host reachable (DNS: ${dnsTime}ms, TCP: ${Date.now() - connectStart}ms)`
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
const responseTime = Date.now() - startTime;
|
||||
resolve({
|
||||
status: 'failed',
|
||||
response_time: responseTime,
|
||||
message: `Connection failed: ${error.message}`
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('timeout', () => {
|
||||
socket.destroy();
|
||||
const responseTime = Date.now() - startTime;
|
||||
resolve({
|
||||
status: 'failed',
|
||||
response_time: responseTime,
|
||||
message: 'Connection timeout'
|
||||
});
|
||||
});
|
||||
|
||||
socket.connect(port, hostname);
|
||||
});
|
||||
} catch (error) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
return {
|
||||
status: 'failed',
|
||||
response_time: responseTime,
|
||||
message: `DNS lookup failed: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute port check
|
||||
*/
|
||||
async function checkPort(monitor) {
|
||||
const startTime = Date.now();
|
||||
const hostname = monitor.target;
|
||||
const port = monitor.port || 80;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
socket.setTimeout(5000);
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.destroy();
|
||||
const responseTime = Date.now() - startTime;
|
||||
resolve({
|
||||
status: 'success',
|
||||
response_time: responseTime,
|
||||
message: `Port ${port} is open`
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
const responseTime = Date.now() - startTime;
|
||||
resolve({
|
||||
status: 'failed',
|
||||
response_time: responseTime,
|
||||
message: `Port ${port} is closed or unreachable: ${error.message}`
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('timeout', () => {
|
||||
socket.destroy();
|
||||
const responseTime = Date.now() - startTime;
|
||||
resolve({
|
||||
status: 'failed',
|
||||
response_time: responseTime,
|
||||
message: `Port ${port} connection timeout`
|
||||
});
|
||||
});
|
||||
|
||||
socket.connect(port, hostname);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a monitor check based on its type
|
||||
*/
|
||||
async function executeMonitorCheck(monitor) {
|
||||
try {
|
||||
let result;
|
||||
|
||||
switch (monitor.type) {
|
||||
case 'http_status':
|
||||
result = await checkHttpStatus(monitor);
|
||||
break;
|
||||
case 'ping':
|
||||
result = await checkPing(monitor);
|
||||
break;
|
||||
case 'port_check':
|
||||
result = await checkPort(monitor);
|
||||
break;
|
||||
default:
|
||||
result = {
|
||||
status: 'failed',
|
||||
message: `Unknown monitor type: ${monitor.type}`
|
||||
};
|
||||
}
|
||||
|
||||
// Save result to database
|
||||
await db.saveMonitorResult(monitor.id, result);
|
||||
|
||||
// Update monitor's updated_at timestamp to track last check time
|
||||
await db.pool.query(
|
||||
'UPDATE synthetic_monitors SET updated_at = NOW() WHERE id = $1',
|
||||
[monitor.id]
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`[Monitor Runner] Error executing check for monitor ${monitor.id}:`, {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
monitorId: monitor.id,
|
||||
monitorName: monitor.name,
|
||||
monitorType: monitor.type
|
||||
});
|
||||
await db.saveMonitorResult(monitor.id, {
|
||||
status: 'failed',
|
||||
message: `Check execution error: ${error.message}`
|
||||
});
|
||||
return {
|
||||
status: 'failed',
|
||||
message: `Check execution error: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a monitor needs to be executed based on its interval
|
||||
*/
|
||||
function shouldExecuteMonitor(monitor) {
|
||||
const now = new Date();
|
||||
const lastCheck = monitor.updated_at ? new Date(monitor.updated_at) : new Date(0);
|
||||
const intervalMs = (monitor.interval || 60) * 1000;
|
||||
|
||||
return (now - lastCheck) >= intervalMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run checks for all enabled monitors that need checking
|
||||
*/
|
||||
async function runMonitorChecks() {
|
||||
try {
|
||||
const monitors = await db.getEnabledMonitors();
|
||||
const monitorsToCheck = monitors.filter(shouldExecuteMonitor);
|
||||
|
||||
if (monitorsToCheck.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute checks in parallel (with limit to avoid overwhelming the system)
|
||||
const batchSize = 10;
|
||||
for (let i = 0; i < monitorsToCheck.length; i += batchSize) {
|
||||
const batch = monitorsToCheck.slice(i, i + batchSize);
|
||||
await Promise.all(batch.map(executeMonitorCheck));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Monitor Runner] Error running monitor checks:', {
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the monitor runner
|
||||
*/
|
||||
function startMonitorRunner() {
|
||||
// Run checks immediately on startup
|
||||
runMonitorChecks();
|
||||
|
||||
// Then run checks every 30 seconds
|
||||
setInterval(runMonitorChecks, 30000);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runMonitorChecks,
|
||||
startMonitorRunner,
|
||||
executeMonitorCheck
|
||||
};
|
||||
504
server/backend/src/osvClient.js
Normal file
504
server/backend/src/osvClient.js
Normal file
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* OSV (Open Source Vulnerabilities) API Client
|
||||
* Queries the OSV API for vulnerability information
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
|
||||
const OSV_API_URL = 'https://api.osv.dev/v1';
|
||||
|
||||
/**
|
||||
* Query OSV API for vulnerabilities affecting a package version
|
||||
* @param {string} packageName - Package name
|
||||
* @param {string} version - Package version
|
||||
* @param {string} ecosystem - Ecosystem (e.g., 'Ubuntu', 'Debian')
|
||||
* @returns {Promise<Array>} Array of vulnerability objects
|
||||
*/
|
||||
async function queryPackage(packageName, version, ecosystem = 'Ubuntu') {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${OSV_API_URL}/query`,
|
||||
{
|
||||
package: {
|
||||
name: packageName,
|
||||
ecosystem: ecosystem
|
||||
},
|
||||
version: version
|
||||
},
|
||||
{
|
||||
timeout: 30000 // 30 second timeout
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data && response.data.vulns) {
|
||||
return response.data.vulns;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
console.error(`[OSV Client] API error for ${packageName}@${version}:`, {
|
||||
status: error.response.status,
|
||||
statusText: error.response.statusText,
|
||||
data: error.response.data
|
||||
});
|
||||
} else if (error.request) {
|
||||
console.error(`[OSV Client] Network error for ${packageName}@${version}:`, {
|
||||
message: error.message
|
||||
});
|
||||
} else {
|
||||
console.error(`[OSV Client] Error querying ${packageName}@${version}:`, {
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch full vulnerability details by ID
|
||||
* @param {string} vulnId - OSV vulnerability ID
|
||||
* @returns {Promise<Object|null>} Full vulnerability object or null
|
||||
*/
|
||||
async function getVulnerabilityDetails(vulnId) {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${OSV_API_URL}/vulns/${encodeURIComponent(vulnId)}`,
|
||||
{
|
||||
timeout: 30000
|
||||
}
|
||||
);
|
||||
|
||||
return response.data || null;
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 404) {
|
||||
console.error(`[OSV Client] Vulnerability not found: ${vulnId}`);
|
||||
return null;
|
||||
}
|
||||
console.error(`[OSV Client] Error fetching vulnerability ${vulnId}:`, {
|
||||
message: error.message,
|
||||
status: error.response?.status
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch fetch full vulnerability details
|
||||
* @param {Array<string>} vulnIds - Array of vulnerability IDs
|
||||
* @returns {Promise<Array>} Array of full vulnerability objects
|
||||
*/
|
||||
async function batchGetVulnerabilityDetails(vulnIds) {
|
||||
if (!vulnIds || vulnIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch in parallel batches to avoid overwhelming the API
|
||||
const batchSize = 10;
|
||||
const results = [];
|
||||
|
||||
for (let i = 0; i < vulnIds.length; i += batchSize) {
|
||||
const batch = vulnIds.slice(i, i + batchSize);
|
||||
const batchPromises = batch.map(id => getVulnerabilityDetails(id));
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
results.push(...batchResults.filter(v => v !== null));
|
||||
|
||||
// Small delay between batches to be respectful to the API
|
||||
if (i + batchSize < vulnIds.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch query OSV API for multiple packages
|
||||
* @param {Array} packages - Array of {name, version, ecosystem} objects
|
||||
* @returns {Promise<Array>} Array of {package, vulnerabilities} objects with full vulnerability details
|
||||
*/
|
||||
async function queryBatch(packages) {
|
||||
if (!packages || packages.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const queries = packages.map(pkg => ({
|
||||
package: {
|
||||
name: pkg.name,
|
||||
ecosystem: pkg.ecosystem || 'Ubuntu'
|
||||
},
|
||||
version: pkg.version
|
||||
}));
|
||||
|
||||
const response = await axios.post(
|
||||
`${OSV_API_URL}/querybatch`,
|
||||
{
|
||||
queries: queries
|
||||
},
|
||||
{
|
||||
timeout: 60000 // 60 second timeout for batch queries
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data && response.data.results) {
|
||||
// Collect all unique vulnerability IDs
|
||||
const vulnIdSet = new Set();
|
||||
const packageVulnMap = new Map();
|
||||
|
||||
response.data.results.forEach((result, index) => {
|
||||
const vulnIds = (result.vulns || []).map(v => v.id || v).filter(Boolean);
|
||||
packageVulnMap.set(index, vulnIds);
|
||||
vulnIds.forEach(id => vulnIdSet.add(id));
|
||||
});
|
||||
|
||||
// Fetch full vulnerability details for all unique IDs
|
||||
const uniqueVulnIds = Array.from(vulnIdSet);
|
||||
console.log(`[OSV Client] Fetching full details for ${uniqueVulnIds.length} unique vulnerabilities...`);
|
||||
const fullVulns = await batchGetVulnerabilityDetails(uniqueVulnIds);
|
||||
|
||||
// Create a map of ID to full vulnerability object
|
||||
const vulnMap = new Map();
|
||||
fullVulns.forEach(vuln => {
|
||||
if (vuln.id) {
|
||||
vulnMap.set(vuln.id, vuln);
|
||||
}
|
||||
});
|
||||
|
||||
// Map back to packages with full vulnerability objects
|
||||
return response.data.results.map((result, index) => {
|
||||
const vulnIds = packageVulnMap.get(index) || [];
|
||||
const vulnerabilities = vulnIds
|
||||
.map(id => vulnMap.get(id))
|
||||
.filter(v => v !== undefined);
|
||||
|
||||
return {
|
||||
package: packages[index],
|
||||
vulnerabilities: vulnerabilities
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
console.error('[OSV Client] Batch API error:', {
|
||||
status: error.response.status,
|
||||
statusText: error.response.statusText,
|
||||
url: `${OSV_API_URL}/querybatch`,
|
||||
packageCount: packages.length,
|
||||
responseData: error.response.data,
|
||||
sampleQueries: queries.slice(0, 3) // Show first 3 queries for debugging
|
||||
});
|
||||
} else if (error.request) {
|
||||
console.error('[OSV Client] Batch network error:', {
|
||||
message: error.message,
|
||||
url: `${OSV_API_URL}/querybatch`,
|
||||
packageCount: packages.length
|
||||
});
|
||||
} else {
|
||||
console.error('[OSV Client] Batch query error:', {
|
||||
message: error.message,
|
||||
url: `${OSV_API_URL}/querybatch`,
|
||||
packageCount: packages.length
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CVSS vector string to extract numeric score
|
||||
* CVSS vectors are in format: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
|
||||
* We'll extract the base score by parsing the vector components
|
||||
* @param {string} vectorString - CVSS vector string
|
||||
* @returns {number|null} Numeric CVSS score or null
|
||||
*/
|
||||
function parseCvssScore(vectorString) {
|
||||
if (!vectorString || typeof vectorString !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to extract numeric score if present (some vectors include score)
|
||||
const scoreMatch = vectorString.match(/CVSS:\d+\.\d+\/([^/]+)/);
|
||||
if (!scoreMatch) return null;
|
||||
|
||||
// For now, we'll use a simplified approach:
|
||||
// Parse the vector components to estimate severity
|
||||
// C (Confidentiality), I (Integrity), A (Availability)
|
||||
const components = vectorString.split('/');
|
||||
let hasHighImpact = false;
|
||||
|
||||
for (const comp of components) {
|
||||
if (comp.startsWith('C:') || comp.startsWith('I:') || comp.startsWith('A:')) {
|
||||
const value = comp.split(':')[1];
|
||||
if (value === 'H') {
|
||||
hasHighImpact = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This is a simplified estimation - in production, you'd want a full CVSS calculator
|
||||
// For now, return null and let the Ubuntu priority handle it
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CVSS vector string to extract base score
|
||||
* CVSS vectors are in format: CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H
|
||||
* We'll parse the vector components to estimate severity
|
||||
* @param {string} vectorString - CVSS vector string
|
||||
* @returns {number|null} Estimated CVSS score or null
|
||||
*/
|
||||
function parseCvssVector(vectorString) {
|
||||
if (!vectorString || typeof vectorString !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract components from vector string
|
||||
const components = vectorString.split('/');
|
||||
let av = null, ac = null, pr = null, ui = null, s = null, c = null, i = null, a = null;
|
||||
|
||||
for (const comp of components) {
|
||||
if (comp.startsWith('AV:')) av = comp.split(':')[1];
|
||||
else if (comp.startsWith('AC:')) ac = comp.split(':')[1];
|
||||
else if (comp.startsWith('PR:')) pr = comp.split(':')[1];
|
||||
else if (comp.startsWith('UI:')) ui = comp.split(':')[1];
|
||||
else if (comp.startsWith('S:')) s = comp.split(':')[1];
|
||||
else if (comp.startsWith('C:')) c = comp.split(':')[1];
|
||||
else if (comp.startsWith('I:')) i = comp.split(':')[1];
|
||||
else if (comp.startsWith('A:')) a = comp.split(':')[1];
|
||||
}
|
||||
|
||||
// Simplified scoring based on impact (C, I, A)
|
||||
// H = High, L = Low, N = None
|
||||
const impactScore =
|
||||
(c === 'H' ? 0.66 : c === 'L' ? 0.22 : 0) +
|
||||
(i === 'H' ? 0.66 : i === 'L' ? 0.22 : 0) +
|
||||
(a === 'H' ? 0.66 : a === 'L' ? 0.22 : 0);
|
||||
|
||||
// Base score approximation (simplified)
|
||||
if (impactScore >= 1.8) return 9.0; // Critical
|
||||
if (impactScore >= 1.2) return 7.0; // High
|
||||
if (impactScore >= 0.4) return 4.0; // Medium
|
||||
if (impactScore > 0) return 2.0; // Low
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract severity from OSV vulnerability object
|
||||
* @param {Object} vuln - OSV vulnerability object
|
||||
* @returns {string} Severity level (CRITICAL, HIGH, MEDIUM, LOW) or null
|
||||
*/
|
||||
function extractSeverity(vuln) {
|
||||
// Priority 1: Check Ubuntu-specific priority (most reliable for Ubuntu CVEs)
|
||||
if (vuln.severity && Array.isArray(vuln.severity)) {
|
||||
for (const sev of vuln.severity) {
|
||||
if (sev.type === 'Ubuntu' && sev.score) {
|
||||
const ubuntuPriority = sev.score.toLowerCase();
|
||||
// Map Ubuntu priorities to our severity levels
|
||||
if (ubuntuPriority === 'critical') return 'CRITICAL';
|
||||
if (ubuntuPriority === 'high') return 'HIGH';
|
||||
if (ubuntuPriority === 'medium') return 'MEDIUM';
|
||||
if (ubuntuPriority === 'low' || ubuntuPriority === 'negligible') return 'LOW';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Parse CVSS vectors from severity array
|
||||
if (vuln.severity && Array.isArray(vuln.severity)) {
|
||||
for (const sev of vuln.severity) {
|
||||
if (sev.type && (sev.type.startsWith('CVSS_V3') || sev.type.startsWith('CVSS_V4')) && sev.score) {
|
||||
// Parse CVSS vector string to estimate score
|
||||
const estimatedScore = parseCvssVector(sev.score);
|
||||
if (estimatedScore !== null) {
|
||||
if (estimatedScore >= 9.0) return 'CRITICAL';
|
||||
if (estimatedScore >= 7.0) return 'HIGH';
|
||||
if (estimatedScore >= 4.0) return 'MEDIUM';
|
||||
if (estimatedScore > 0) return 'LOW';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Check database_specific for Ubuntu severity
|
||||
if (vuln.database_specific) {
|
||||
if (vuln.database_specific.severity) {
|
||||
const sev = vuln.database_specific.severity.toLowerCase();
|
||||
if (sev === 'critical') return 'CRITICAL';
|
||||
if (sev === 'high') return 'HIGH';
|
||||
if (sev === 'medium') return 'MEDIUM';
|
||||
if (sev === 'low' || sev === 'negligible') return 'LOW';
|
||||
}
|
||||
// Check for numeric CVSS score in database_specific
|
||||
if (vuln.database_specific.cvss_score !== undefined) {
|
||||
const score = parseFloat(vuln.database_specific.cvss_score);
|
||||
if (score >= 9.0) return 'CRITICAL';
|
||||
if (score >= 7.0) return 'HIGH';
|
||||
if (score >= 4.0) return 'MEDIUM';
|
||||
if (score > 0) return 'LOW';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract CVE ID from OSV vulnerability object
|
||||
* OSV vulnerabilities have the actual CVE ID in the "upstream" array
|
||||
* @param {Object} vuln - OSV vulnerability object
|
||||
* @returns {string|null} Standard CVE ID or null
|
||||
*/
|
||||
function extractCveId(vuln) {
|
||||
if (!vuln) return null;
|
||||
|
||||
// First, try to get CVE from upstream array (most reliable)
|
||||
if (vuln.upstream && Array.isArray(vuln.upstream)) {
|
||||
const cveId = vuln.upstream.find(id => id.startsWith('CVE-'));
|
||||
if (cveId) {
|
||||
return cveId;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to extract from OSV ID
|
||||
const osvId = vuln.id || '';
|
||||
|
||||
// Handle Ubuntu-specific format: UBUNTU-CVE-2022-49737 -> CVE-2022-49737
|
||||
if (osvId.startsWith('UBUNTU-CVE-')) {
|
||||
return osvId.replace('UBUNTU-CVE-', 'CVE-');
|
||||
}
|
||||
|
||||
// Handle standard CVE format
|
||||
if (osvId.startsWith('CVE-')) {
|
||||
return osvId;
|
||||
}
|
||||
|
||||
// For other formats (GHSA, etc.), return as-is but note it's not a CVE
|
||||
return osvId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize OSV vulnerability to our format
|
||||
* @param {Object} vuln - OSV vulnerability object
|
||||
* @param {string} packageName - Package name
|
||||
* @param {string} ecosystem - Ecosystem
|
||||
* @param {string} installedVersion - Installed package version (optional)
|
||||
* @returns {Object} Normalized vulnerability object
|
||||
*/
|
||||
function normalizeVulnerability(vuln, packageName, ecosystem, installedVersion = null) {
|
||||
// Extract proper CVE ID from vulnerability object (checks upstream array first)
|
||||
const cveId = extractCveId(vuln);
|
||||
|
||||
return {
|
||||
cve_id: cveId, // Store the normalized CVE ID
|
||||
osv_id: vuln.id, // Keep original OSV ID for reference
|
||||
package_name: packageName,
|
||||
ecosystem: ecosystem,
|
||||
severity: extractSeverity(vuln),
|
||||
summary: vuln.summary || null,
|
||||
description: vuln.details || vuln.summary || null,
|
||||
fixed_version: extractFixedVersion(vuln, ecosystem, installedVersion),
|
||||
affected_version_range: JSON.stringify(vuln.affected || []),
|
||||
published_at: vuln.published ? new Date(vuln.published) : null,
|
||||
modified_at: vuln.modified ? new Date(vuln.modified) : null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract fixed version from OSV vulnerability
|
||||
* @param {Object} vuln - OSV vulnerability object
|
||||
* @param {string} ecosystem - Ecosystem (e.g., 'Ubuntu:24.04:LTS')
|
||||
* @param {string} installedVersion - Installed package version
|
||||
* @returns {string|null} Fixed version or null
|
||||
*/
|
||||
function extractFixedVersion(vuln, ecosystem = null, installedVersion = null) {
|
||||
if (!vuln.affected || vuln.affected.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the affected entry that matches our ecosystem
|
||||
let matchingAffected = null;
|
||||
for (const affected of vuln.affected) {
|
||||
if (affected.package && affected.package.ecosystem) {
|
||||
const affectedEco = affected.package.ecosystem;
|
||||
// Match ecosystem (e.g., "Ubuntu:24.04:LTS")
|
||||
if (ecosystem && affectedEco === ecosystem) {
|
||||
matchingAffected = affected;
|
||||
break;
|
||||
}
|
||||
// Also check if ecosystem starts with our base (for fallback)
|
||||
if (ecosystem && affectedEco.startsWith(ecosystem.split(':')[0])) {
|
||||
if (!matchingAffected) {
|
||||
matchingAffected = affected;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no exact match, use first affected entry
|
||||
if (!matchingAffected && vuln.affected.length > 0) {
|
||||
matchingAffected = vuln.affected[0];
|
||||
}
|
||||
|
||||
if (!matchingAffected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check ranges for fixed events
|
||||
if (matchingAffected.ranges && matchingAffected.ranges.length > 0) {
|
||||
for (const range of matchingAffected.ranges) {
|
||||
if (range.type === 'ECOSYSTEM' && range.events) {
|
||||
// Look for fixed events (most recent fixed version)
|
||||
let fixedVersion = null;
|
||||
for (const event of range.events) {
|
||||
if (event.fixed) {
|
||||
fixedVersion = event.fixed;
|
||||
}
|
||||
}
|
||||
if (fixedVersion) {
|
||||
return fixedVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For Ubuntu, check if installed version is in the affected versions list
|
||||
// If it is, and there are newer versions, the newest version might be the fix
|
||||
if (matchingAffected.versions && Array.isArray(matchingAffected.versions) && installedVersion) {
|
||||
const versions = matchingAffected.versions;
|
||||
const installedIndex = versions.indexOf(installedVersion);
|
||||
|
||||
// If installed version is in the list and not the last one, return the last version as potential fix
|
||||
// Note: This is a heuristic - Ubuntu might not have fixed it yet
|
||||
if (installedIndex >= 0 && installedIndex < versions.length - 1) {
|
||||
// Check if there's a newer version (potential fix)
|
||||
const lastVersion = versions[versions.length - 1];
|
||||
// Only return if it's clearly newer (simple string comparison might not work for all version formats)
|
||||
// For now, return null - we need better version comparison
|
||||
// return lastVersion;
|
||||
}
|
||||
}
|
||||
|
||||
// Check database_specific for Ubuntu-specific fixed version info
|
||||
if (matchingAffected.database_specific && matchingAffected.database_specific.fixed_version) {
|
||||
return matchingAffected.database_specific.fixed_version;
|
||||
}
|
||||
|
||||
if (vuln.database_specific && vuln.database_specific.fixed_version) {
|
||||
return vuln.database_specific.fixed_version;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
queryPackage,
|
||||
queryBatch,
|
||||
getVulnerabilityDetails,
|
||||
batchGetVulnerabilityDetails,
|
||||
normalizeVulnerability,
|
||||
extractSeverity,
|
||||
extractCveId
|
||||
};
|
||||
90
server/backend/src/packageBuilder.js
Normal file
90
server/backend/src/packageBuilder.js
Normal file
@@ -0,0 +1,90 @@
|
||||
const { exec } = require('child_process');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { promisify } = require('util');
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Build a debian package with embedded configuration
|
||||
*/
|
||||
async function buildDebPackage(serverUrl, serverId, apiKey, outputDir) {
|
||||
const clientDir = path.join(__dirname, '../../../../clients/ubuntu');
|
||||
const buildDir = path.join(outputDir, 'build');
|
||||
const packageDir = path.join(buildDir, 'oculog-client');
|
||||
|
||||
try {
|
||||
// Create build directory
|
||||
await fs.mkdir(buildDir, { recursive: true });
|
||||
await fs.mkdir(packageDir, { recursive: true });
|
||||
|
||||
// Copy client files
|
||||
await execAsync(`cp -r ${clientDir}/* ${packageDir}/`, { cwd: clientDir });
|
||||
|
||||
// Create embedded config file
|
||||
const config = {
|
||||
server_url: serverUrl,
|
||||
server_id: serverId,
|
||||
api_key: apiKey,
|
||||
interval: 30
|
||||
};
|
||||
|
||||
const configPath = path.join(packageDir, 'client.conf');
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
// Update postinst to copy the embedded config
|
||||
const postinstPath = path.join(packageDir, 'debian/postinst');
|
||||
let postinst = await fs.readFile(postinstPath, 'utf8');
|
||||
postinst = postinst.replace(
|
||||
'echo "Please configure /etc/oculog/client.conf before starting the service."',
|
||||
`# Copy embedded configuration
|
||||
if [ -f /opt/oculog/client.conf ]; then
|
||||
cp /opt/oculog/client.conf /etc/oculog/client.conf
|
||||
chmod 600 /etc/oculog/client.conf
|
||||
echo "Configuration installed from package."
|
||||
fi
|
||||
|
||||
# Enable and start service
|
||||
systemctl daemon-reload
|
||||
systemctl enable oculog-client.service 2>/dev/null || true
|
||||
systemctl start oculog-client.service 2>/dev/null || true
|
||||
|
||||
echo "Oculog client installed and started successfully!"`
|
||||
);
|
||||
await fs.writeFile(postinstPath, postinst);
|
||||
|
||||
// Update debian/rules to include config file
|
||||
const rulesPath = path.join(packageDir, 'debian/rules');
|
||||
let rules = await fs.readFile(rulesPath, 'utf8');
|
||||
if (!rules.includes('client.conf')) {
|
||||
rules = rules.replace(
|
||||
'install -m 644 oculog-client.service',
|
||||
`install -m 644 client.conf $(CURDIR)/debian/oculog-client/opt/oculog/
|
||||
\tinstall -m 644 oculog-client.service`
|
||||
);
|
||||
}
|
||||
await fs.writeFile(rulesPath, rules);
|
||||
|
||||
// Build the package
|
||||
const { stdout, stderr } = await execAsync(
|
||||
'dpkg-buildpackage -b -us -uc',
|
||||
{ cwd: packageDir }
|
||||
);
|
||||
|
||||
// Find the generated .deb file
|
||||
const debFiles = await fs.readdir(buildDir);
|
||||
const debFile = debFiles.find(f => f.endsWith('.deb'));
|
||||
|
||||
if (!debFile) {
|
||||
throw new Error('Failed to build debian package');
|
||||
}
|
||||
|
||||
return path.join(buildDir, debFile);
|
||||
} catch (error) {
|
||||
console.error('Error building deb package:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDebPackage
|
||||
};
|
||||
8
server/frontend/.dockerignore
Normal file
8
server/frontend/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
build
|
||||
|
||||
19
server/frontend/Dockerfile
Normal file
19
server/frontend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the development server
|
||||
CMD ["npm", "start"]
|
||||
|
||||
17491
server/frontend/package-lock.json
generated
Normal file
17491
server/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
server/frontend/package.json
Normal file
37
server/frontend/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "oculog-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "Frontend dashboard for Oculog metrics observability platform",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"axios": "^1.6.0",
|
||||
"recharts": "^2.10.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
89
server/frontend/public/index.html
Normal file
89
server/frontend/public/index.html
Normal file
@@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#fc2922" />
|
||||
<meta name="description" content="Oculog - Server Metrics Observability Platform" />
|
||||
<link rel="icon" type="image/png" href="/oculog-logo.png" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<title>Oculog - Server Metrics Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
// Suppress external script errors before React loads
|
||||
(function() {
|
||||
const originalError = window.onerror;
|
||||
window.onerror = function(message, source, lineno, colno, error) {
|
||||
// Suppress errors from browser extensions and external scripts
|
||||
if (
|
||||
message === 'Script error.' ||
|
||||
(typeof message === 'string' && (
|
||||
message.includes('ethereum') ||
|
||||
message.includes('web3') ||
|
||||
message.includes('window.ethereum') ||
|
||||
message.includes('selectedAddress') ||
|
||||
message.includes('undefined is not an object')
|
||||
)) ||
|
||||
(source && (
|
||||
source.includes('chrome-extension://') ||
|
||||
source.includes('moz-extension://') ||
|
||||
source.includes('safari-extension://') ||
|
||||
source === ''
|
||||
))
|
||||
) {
|
||||
return true; // Suppress the error
|
||||
}
|
||||
// Call original handler if it exists
|
||||
if (originalError) {
|
||||
return originalError.apply(this, arguments);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Also suppress React error overlay by intercepting its creation
|
||||
const originalCreateElement = document.createElement;
|
||||
document.createElement = function(tagName) {
|
||||
const element = originalCreateElement.call(document, tagName);
|
||||
|
||||
// If creating an iframe that might be the error overlay
|
||||
if (tagName.toLowerCase() === 'iframe') {
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'src') {
|
||||
const src = element.getAttribute('src') || '';
|
||||
if (src.includes('react-error-overlay')) {
|
||||
// Check if it's an external error
|
||||
setTimeout(function() {
|
||||
try {
|
||||
const iframeDoc = element.contentDocument || element.contentWindow?.document;
|
||||
if (iframeDoc) {
|
||||
const iframeText = iframeDoc.body?.textContent || '';
|
||||
if (
|
||||
iframeText.includes('Script error') ||
|
||||
iframeText.includes('ethereum') ||
|
||||
iframeText.includes('selectedAddress')
|
||||
) {
|
||||
element.remove();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Cross-origin, can't access
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(element, { attributes: true, attributeFilter: ['src'] });
|
||||
}
|
||||
|
||||
return element;
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
BIN
server/frontend/public/oculog-logo.png
Normal file
BIN
server/frontend/public/oculog-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 533 KiB |
298
server/frontend/src/App.css
Normal file
298
server/frontend/src/App.css
Normal file
@@ -0,0 +1,298 @@
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
.left-sidebar-nav {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 70px;
|
||||
height: 100vh;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-right: 1px solid rgba(252, 41, 34, 0.3);
|
||||
border-top: 2px solid #fc2922;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
gap: 0.5rem;
|
||||
z-index: 999;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-logo-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sidebar-logo-link:hover {
|
||||
background: rgba(252, 41, 34, 0.1);
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.nav-icon-button {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(252, 41, 34, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-icon-button:hover {
|
||||
background: rgba(252, 41, 34, 0.1);
|
||||
color: #fc2922;
|
||||
border-color: rgba(252, 41, 34, 0.5);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.nav-icon-button.active {
|
||||
background: #fc2922;
|
||||
color: white;
|
||||
border-color: #fc2922;
|
||||
}
|
||||
|
||||
.nav-icon-button.active:hover {
|
||||
background: #e0241e;
|
||||
border-color: #e0241e;
|
||||
}
|
||||
|
||||
.nav-icon-button svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.nav-icon-button i {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nav-icon-button.download-button {
|
||||
background: transparent !important;
|
||||
border: 1px solid rgba(252, 41, 34, 0.3) !important;
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
.nav-icon-button.download-button:hover {
|
||||
background: rgba(252, 41, 34, 0.15) !important;
|
||||
color: #fc2922 !important;
|
||||
border-color: rgba(252, 41, 34, 0.6) !important;
|
||||
}
|
||||
|
||||
.nav-icon-button.download-button svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.nav-icon-button.download-button svg path,
|
||||
.nav-icon-button.download-button svg polyline,
|
||||
.nav-icon-button.download-button svg line {
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
width: 32px;
|
||||
height: 1px;
|
||||
background: rgba(252, 41, 34, 0.3);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
|
||||
.app-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
padding-left: calc(2rem + 70px); /* Account for left sidebar */
|
||||
display: grid;
|
||||
grid-template-columns: 250px 1fr;
|
||||
gap: 2rem;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-content {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 1rem;
|
||||
padding-left: calc(1rem + 60px);
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.app-content {
|
||||
padding: 0.75rem;
|
||||
padding-left: calc(0.75rem + 56px);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
font-size: 1.5rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.app-content-full {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
padding-left: calc(2rem + 70px); /* Account for left sidebar */
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: rgba(30, 41, 59, 0.98);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
border: 1px solid rgba(252, 41, 34, 0.3);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: rgba(252, 41, 34, 0.1);
|
||||
color: #fc2922;
|
||||
border-color: rgba(252, 41, 34, 0.5);
|
||||
}
|
||||
|
||||
.modal-close svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.left-sidebar-nav {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.nav-icon-button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0.625rem;
|
||||
}
|
||||
|
||||
.nav-icon-button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.app-content,
|
||||
.app-content-full {
|
||||
padding-left: calc(1rem + 60px);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.left-sidebar-nav {
|
||||
width: 56px;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.nav-icon-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-icon-button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.app-content,
|
||||
.app-content-full {
|
||||
padding-left: calc(0.75rem + 56px);
|
||||
}
|
||||
}
|
||||
|
||||
324
server/frontend/src/App.js
Normal file
324
server/frontend/src/App.js
Normal file
@@ -0,0 +1,324 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import './App.css';
|
||||
import MetricsDashboard from './components/MetricsDashboard';
|
||||
import ServerList from './components/ServerList';
|
||||
import ClientDownload from './components/ClientDownload';
|
||||
import SyntheticMonitors from './components/SyntheticMonitors';
|
||||
import Alerting from './components/Alerting';
|
||||
import Security from './components/Security';
|
||||
import SecurityDashboard from './components/SecurityDashboard';
|
||||
import Wiki from './components/Wiki';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001';
|
||||
|
||||
function App() {
|
||||
const [activeView, setActiveView] = useState('servers-metrics'); // 'servers-metrics' or 'synthetic-monitors'
|
||||
const [servers, setServers] = useState([]);
|
||||
const [selectedServer, setSelectedServer] = useState(null);
|
||||
const [metrics, setMetrics] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showDownload, setShowDownload] = useState(false);
|
||||
const [timeRange, setTimeRange] = useState('1h');
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers();
|
||||
if (activeView === 'servers-metrics') {
|
||||
const interval = setInterval(() => {
|
||||
fetchServers();
|
||||
if (selectedServer) {
|
||||
fetchMetrics(selectedServer, timeRange);
|
||||
} else {
|
||||
fetchLatestMetrics();
|
||||
}
|
||||
}, 5000); // Refresh every 5 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [selectedServer, timeRange, activeView]);
|
||||
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/servers`);
|
||||
const sortedServers = [...response.data.servers].sort((a, b) =>
|
||||
a.localeCompare(b, undefined, { sensitivity: 'base' })
|
||||
);
|
||||
setServers(sortedServers);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch servers');
|
||||
setLoading(false);
|
||||
console.error('Error fetching servers:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateStartTime = (range) => {
|
||||
const now = new Date();
|
||||
let startTime = new Date(now);
|
||||
|
||||
switch (range) {
|
||||
case '5m':
|
||||
startTime.setTime(now.getTime() - (5 * 60 * 1000));
|
||||
break;
|
||||
case '15m':
|
||||
startTime.setTime(now.getTime() - (15 * 60 * 1000));
|
||||
break;
|
||||
case '30m':
|
||||
startTime.setTime(now.getTime() - (30 * 60 * 1000));
|
||||
break;
|
||||
case '1h':
|
||||
startTime.setTime(now.getTime() - (60 * 60 * 1000));
|
||||
break;
|
||||
case '3h':
|
||||
startTime.setTime(now.getTime() - (3 * 60 * 60 * 1000));
|
||||
break;
|
||||
case '6h':
|
||||
startTime.setTime(now.getTime() - (6 * 60 * 60 * 1000));
|
||||
break;
|
||||
case '12h':
|
||||
startTime.setTime(now.getTime() - (12 * 60 * 60 * 1000));
|
||||
break;
|
||||
case '1d':
|
||||
startTime.setTime(now.getTime() - (24 * 60 * 60 * 1000));
|
||||
break;
|
||||
case '3d':
|
||||
startTime.setTime(now.getTime() - (3 * 24 * 60 * 60 * 1000));
|
||||
break;
|
||||
case '1w':
|
||||
startTime.setTime(now.getTime() - (7 * 24 * 60 * 60 * 1000));
|
||||
break;
|
||||
case '2w':
|
||||
startTime.setTime(now.getTime() - (14 * 24 * 60 * 60 * 1000));
|
||||
break;
|
||||
case '1mo':
|
||||
// Approximate 30 days
|
||||
startTime.setTime(now.getTime() - (30 * 24 * 60 * 60 * 1000));
|
||||
break;
|
||||
default:
|
||||
startTime.setTime(now.getTime() - (60 * 60 * 1000)); // Default to 1 hour
|
||||
}
|
||||
|
||||
return startTime.toISOString();
|
||||
};
|
||||
|
||||
const calculateLimit = (range) => {
|
||||
// Calculate approximate number of data points needed
|
||||
// Assuming metrics are collected every 30 seconds
|
||||
const pointsPerHour = 120; // 3600 seconds / 30 seconds
|
||||
|
||||
switch (range) {
|
||||
case '5m':
|
||||
return 10; // 5 minutes / 30 seconds = ~10 points
|
||||
case '15m':
|
||||
return 30; // 15 minutes / 30 seconds = 30 points
|
||||
case '30m':
|
||||
return 60; // 30 minutes / 30 seconds = 60 points
|
||||
case '1h':
|
||||
return pointsPerHour; // 120
|
||||
case '3h':
|
||||
return pointsPerHour * 3; // 360
|
||||
case '6h':
|
||||
return pointsPerHour * 6; // 720
|
||||
case '12h':
|
||||
return pointsPerHour * 12; // 1440
|
||||
case '1d':
|
||||
return pointsPerHour * 24; // 2880
|
||||
case '3d':
|
||||
return pointsPerHour * 72; // 8640
|
||||
case '1w':
|
||||
return pointsPerHour * 168; // 20160
|
||||
case '2w':
|
||||
return pointsPerHour * 336; // 40320
|
||||
case '1mo':
|
||||
return pointsPerHour * 720; // 86400 (30 days)
|
||||
default:
|
||||
return 1000;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMetrics = async (serverId, range = '3h') => {
|
||||
try {
|
||||
const startTime = calculateStartTime(range);
|
||||
const limit = calculateLimit(range);
|
||||
const response = await axios.get(`${API_URL}/api/servers/${serverId}/metrics`, {
|
||||
params: {
|
||||
startTime: startTime,
|
||||
limit: limit
|
||||
}
|
||||
});
|
||||
setMetrics({ [serverId]: response.data.metrics });
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(`Failed to fetch metrics for ${serverId}`);
|
||||
console.error('Error fetching metrics:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchLatestMetrics = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/metrics/latest`);
|
||||
setMetrics(response.data.metrics);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch latest metrics');
|
||||
console.error('Error fetching latest metrics:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerSelect = (serverId) => {
|
||||
setSelectedServer(serverId);
|
||||
fetchMetrics(serverId, timeRange);
|
||||
};
|
||||
|
||||
const handleTimeRangeChange = (newRange) => {
|
||||
setTimeRange(newRange);
|
||||
if (selectedServer) {
|
||||
fetchMetrics(selectedServer, newRange);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewAll = () => {
|
||||
setSelectedServer(null);
|
||||
fetchLatestMetrics();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="loading">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<nav className="left-sidebar-nav">
|
||||
<a href="https://ormentia.com" className="sidebar-logo-link">
|
||||
<img src="/oculog-logo.png" alt="Oculog Logo" className="sidebar-logo" />
|
||||
</a>
|
||||
<div className="nav-divider"></div>
|
||||
<button
|
||||
className={`nav-icon-button ${activeView === 'servers-metrics' ? 'active' : ''}`}
|
||||
onClick={() => setActiveView('servers-metrics')}
|
||||
title="Servers Metrics"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="3" width="20" height="4" rx="1" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||
<rect x="2" y="10" width="20" height="4" rx="1" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||
<rect x="2" y="17" width="20" height="4" rx="1" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`nav-icon-button ${activeView === 'synthetic-monitors' ? 'active' : ''}`}
|
||||
onClick={() => setActiveView('synthetic-monitors')}
|
||||
title="Synthetic Monitors"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 3v18h18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M7 12l4-4 4 4 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`nav-icon-button ${activeView === 'alerting' ? 'active' : ''}`}
|
||||
onClick={() => setActiveView('alerting')}
|
||||
title="Alerting"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`nav-icon-button ${activeView === 'security' ? 'active' : ''}`}
|
||||
onClick={() => setActiveView('security')}
|
||||
title="Security"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`nav-icon-button ${activeView === 'wiki' ? 'active' : ''}`}
|
||||
onClick={() => setActiveView('wiki')}
|
||||
title="Wiki"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div className="nav-divider"></div>
|
||||
<button
|
||||
className="nav-icon-button download-button"
|
||||
onClick={() => setShowDownload(true)}
|
||||
title="Download Client"
|
||||
>
|
||||
<i className="fas fa-plus"></i>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{showDownload && (
|
||||
<div className="modal-overlay" onClick={() => setShowDownload(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={() => setShowDownload(false)}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<ClientDownload onClose={() => setShowDownload(false)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeView === 'servers-metrics' && (
|
||||
<div className="app-content">
|
||||
<ServerList
|
||||
servers={servers}
|
||||
selectedServer={selectedServer}
|
||||
onSelect={handleServerSelect}
|
||||
onViewAll={handleViewAll}
|
||||
/>
|
||||
|
||||
<MetricsDashboard
|
||||
metrics={metrics}
|
||||
selectedServer={selectedServer}
|
||||
timeRange={timeRange}
|
||||
onTimeRangeChange={handleTimeRangeChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeView === 'synthetic-monitors' && (
|
||||
<div className="app-content-full">
|
||||
<SyntheticMonitors />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeView === 'alerting' && (
|
||||
<div className="app-content-full">
|
||||
<Alerting />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeView === 'security' && (
|
||||
<div className="app-content-full">
|
||||
<SecurityDashboard />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeView === 'wiki' && (
|
||||
<div className="app-content-full">
|
||||
<Wiki />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
399
server/frontend/src/components/Alerting.css
Normal file
399
server/frontend/src/components/Alerting.css
Normal file
@@ -0,0 +1,399 @@
|
||||
.alerting {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.alerting-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.alerting-header h2 {
|
||||
margin: 0;
|
||||
color: #f1f5f9;
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
|
||||
.add-policy-button {
|
||||
background: #fc2922;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.add-policy-button:hover {
|
||||
background: #e0241e;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(252, 41, 34, 0.4);
|
||||
}
|
||||
|
||||
.alerting-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.alerting-tabs .tab-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 0.75rem 1.5rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.alerting-tabs .tab-button:hover {
|
||||
color: #f1f5f9;
|
||||
border-bottom-color: rgba(252, 41, 34, 0.5);
|
||||
}
|
||||
|
||||
.alerting-tabs .tab-button.active {
|
||||
color: #fc2922;
|
||||
border-bottom-color: #fc2922;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.no-policies,
|
||||
.no-alerts {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.no-policies p,
|
||||
.no-alerts p {
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.policies-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.policy-card {
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border: 1px solid rgba(252, 41, 34, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.policy-card:hover {
|
||||
border-color: rgba(252, 41, 34, 0.4);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.policy-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.policy-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.policy-title h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #f1f5f9;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-badge.enabled {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.status-badge.disabled {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.policy-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toggle-button,
|
||||
.edit-button,
|
||||
.delete-button {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 0.375rem 0.625rem;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
border-color: #10b981;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.edit-button:hover {
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.delete-button:hover {
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.policy-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #f1f5f9;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.alerts-history {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.alerts-table-container {
|
||||
overflow-x: auto;
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(252, 41, 34, 0.2);
|
||||
}
|
||||
|
||||
.alerts-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.alerts-table thead {
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.alerts-table th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.alerts-table td {
|
||||
padding: 1rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.alerts-table tbody tr:hover {
|
||||
background: rgba(252, 41, 34, 0.05);
|
||||
}
|
||||
|
||||
.alerts-table tbody tr.alert-active {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.alerts-table tbody tr.alert-active td {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.alert-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.alert-badge.active {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.alert-badge.resolved {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #1e293b;
|
||||
border: 1px solid rgba(252, 41, 34, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #f1f5f9;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"],
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.875rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input[type="text"]:focus,
|
||||
.form-group input[type="number"]:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #fc2922;
|
||||
}
|
||||
|
||||
.form-group input[type="checkbox"] {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.form-actions button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-actions button[type="button"] {
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
}
|
||||
|
||||
.form-actions button[type="button"]:hover {
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
border-color: rgba(148, 163, 184, 0.5);
|
||||
}
|
||||
|
||||
.form-actions button[type="submit"] {
|
||||
background: #fc2922;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.form-actions button[type="submit"]:hover {
|
||||
background: #e0241e;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.alerting {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.alerting-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.policies-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.alerts-table-container {
|
||||
overflow-x: scroll;
|
||||
}
|
||||
|
||||
.alerts-table {
|
||||
min-width: 800px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
442
server/frontend/src/components/Alerting.js
Normal file
442
server/frontend/src/components/Alerting.js
Normal file
@@ -0,0 +1,442 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import './Alerting.css';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001';
|
||||
|
||||
function Alerting() {
|
||||
const [activeTab, setActiveTab] = useState('policies'); // 'policies' or 'history'
|
||||
const [policies, setPolicies] = useState([]);
|
||||
const [alerts, setAlerts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [editingPolicy, setEditingPolicy] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
type: 'server_metric', // 'server_metric' or 'synthetic_monitor'
|
||||
metric_type: 'cpu_high', // cpu_high, ram_high, disk_used, server_not_reporting, client_out_of_date
|
||||
monitor_id: null, // for synthetic_monitor type
|
||||
threshold: 80,
|
||||
server_id: null, // null for all servers, or specific server_id
|
||||
enabled: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchPolicies();
|
||||
fetchAlerts();
|
||||
const interval = setInterval(() => {
|
||||
fetchPolicies();
|
||||
fetchAlerts();
|
||||
}, 30000); // Refresh every 30 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchPolicies = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/alert-policies`);
|
||||
setPolicies(response.data.policies || []);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch alert policies');
|
||||
setLoading(false);
|
||||
console.error('Error fetching policies:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAlerts = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/alerts`);
|
||||
setAlerts(response.data.alerts || []);
|
||||
} catch (err) {
|
||||
console.error('Error fetching alerts:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingPolicy(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
type: 'server_metric',
|
||||
metric_type: 'cpu_high',
|
||||
monitor_id: null,
|
||||
threshold: 80,
|
||||
server_id: null,
|
||||
enabled: true
|
||||
});
|
||||
setShowAddModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (policy) => {
|
||||
setEditingPolicy(policy);
|
||||
setFormData({
|
||||
name: policy.name,
|
||||
type: policy.type,
|
||||
metric_type: policy.metric_type || 'cpu_high',
|
||||
monitor_id: policy.monitor_id || null,
|
||||
threshold: policy.threshold || 80,
|
||||
server_id: policy.server_id || null,
|
||||
enabled: policy.enabled !== false
|
||||
});
|
||||
setShowAddModal(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingPolicy) {
|
||||
await axios.put(`${API_URL}/api/alert-policies/${editingPolicy.id}`, formData);
|
||||
} else {
|
||||
await axios.post(`${API_URL}/api/alert-policies`, formData);
|
||||
}
|
||||
setShowAddModal(false);
|
||||
fetchPolicies();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to save policy');
|
||||
console.error('Error saving policy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!window.confirm('Are you sure you want to delete this alert policy?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await axios.delete(`${API_URL}/api/alert-policies/${id}`);
|
||||
fetchPolicies();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to delete policy');
|
||||
console.error('Error deleting policy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (policy) => {
|
||||
try {
|
||||
await axios.put(`${API_URL}/api/alert-policies/${policy.id}`, {
|
||||
...policy,
|
||||
enabled: !policy.enabled
|
||||
});
|
||||
fetchPolicies();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to update policy');
|
||||
console.error('Error updating policy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const getMetricTypeLabel = (type, metricType) => {
|
||||
if (type === 'synthetic_monitor') {
|
||||
return 'Synthetic Monitor Failure';
|
||||
}
|
||||
const labels = {
|
||||
cpu_high: 'CPU High',
|
||||
ram_high: 'RAM High',
|
||||
disk_used: 'Disk Used %',
|
||||
server_not_reporting: 'Server Not Reporting',
|
||||
client_out_of_date: 'Client Out of Date'
|
||||
};
|
||||
return labels[metricType] || metricType;
|
||||
};
|
||||
|
||||
const getAlertStatusBadge = (alert) => {
|
||||
if (alert.status === 'active') {
|
||||
return <span className="alert-badge active">Active</span>;
|
||||
}
|
||||
return <span className="alert-badge resolved">Resolved</span>;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="alerting">
|
||||
<div className="loading">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="alerting">
|
||||
<div className="alerting-header">
|
||||
<h2>Alerting</h2>
|
||||
{activeTab === 'policies' && (
|
||||
<button className="add-policy-button" onClick={handleAdd}>
|
||||
+ Add Policy
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="alerting-tabs">
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'policies' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('policies')}
|
||||
>
|
||||
Alert Policies
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'history' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('history')}
|
||||
>
|
||||
Alert History
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{activeTab === 'policies' && (
|
||||
<>
|
||||
{policies.length === 0 ? (
|
||||
<div className="no-policies">
|
||||
<p>No alert policies configured</p>
|
||||
<button className="add-policy-button" onClick={handleAdd}>
|
||||
Add Your First Policy
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="policies-grid">
|
||||
{policies.map((policy) => (
|
||||
<div key={policy.id} className="policy-card">
|
||||
<div className="policy-header">
|
||||
<div className="policy-title">
|
||||
<h3>{policy.name}</h3>
|
||||
<span className={`status-badge ${policy.enabled ? 'enabled' : 'disabled'}`}>
|
||||
{policy.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="policy-actions">
|
||||
<button
|
||||
className="toggle-button"
|
||||
onClick={() => handleToggleEnabled(policy)}
|
||||
title={policy.enabled ? 'Disable' : 'Enable'}
|
||||
>
|
||||
{policy.enabled ? '●' : '○'}
|
||||
</button>
|
||||
<button
|
||||
className="edit-button"
|
||||
onClick={() => handleEdit(policy)}
|
||||
title="Edit"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
className="delete-button"
|
||||
onClick={() => handleDelete(policy.id)}
|
||||
title="Delete"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="policy-details">
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Type:</span>
|
||||
<span className="detail-value">
|
||||
{policy.type === 'server_metric' ? 'Server Metric' : 'Synthetic Monitor'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Condition:</span>
|
||||
<span className="detail-value">
|
||||
{getMetricTypeLabel(policy.type, policy.metric_type)}
|
||||
</span>
|
||||
</div>
|
||||
{policy.type === 'server_metric' && policy.metric_type !== 'server_not_reporting' && policy.metric_type !== 'client_out_of_date' && (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Threshold:</span>
|
||||
<span className="detail-value">{policy.threshold}%</span>
|
||||
</div>
|
||||
)}
|
||||
{policy.type === 'server_metric' && policy.metric_type === 'server_not_reporting' && (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Threshold:</span>
|
||||
<span className="detail-value">{policy.threshold} minutes</span>
|
||||
</div>
|
||||
)}
|
||||
{policy.server_id ? (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Server:</span>
|
||||
<span className="detail-value">{policy.server_id}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Scope:</span>
|
||||
<span className="detail-value">All Servers</span>
|
||||
</div>
|
||||
)}
|
||||
{policy.type === 'synthetic_monitor' && policy.monitor_id && (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Monitor ID:</span>
|
||||
<span className="detail-value">{policy.monitor_id}</span>
|
||||
</div>
|
||||
)}
|
||||
{policy.type === 'synthetic_monitor' && !policy.monitor_id && (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Scope:</span>
|
||||
<span className="detail-value">All Monitors</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<div className="alerts-history">
|
||||
{alerts.length === 0 ? (
|
||||
<div className="no-alerts">
|
||||
<p>No alerts in history</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="alerts-table-container">
|
||||
<table className="alerts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Policy</th>
|
||||
<th>Target</th>
|
||||
<th>Message</th>
|
||||
<th>Triggered</th>
|
||||
<th>Resolved</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{alerts.map((alert) => (
|
||||
<tr key={alert.id} className={alert.status === 'active' ? 'alert-active' : ''}>
|
||||
<td>{getAlertStatusBadge(alert)}</td>
|
||||
<td>{alert.policy_name || 'N/A'}</td>
|
||||
<td>{alert.target || 'N/A'}</td>
|
||||
<td>{alert.message}</td>
|
||||
<td>{new Date(alert.triggered_at).toLocaleString()}</td>
|
||||
<td>{alert.resolved_at ? new Date(alert.resolved_at).toLocaleString() : '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowAddModal(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{editingPolicy ? 'Edit Alert Policy' : 'Add Alert Policy'}</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
placeholder="e.g., High CPU Alert"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Type</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => {
|
||||
const newType = e.target.value;
|
||||
setFormData({
|
||||
...formData,
|
||||
type: newType,
|
||||
metric_type: newType === 'server_metric' ? 'cpu_high' : null,
|
||||
monitor_id: null
|
||||
});
|
||||
}}
|
||||
required
|
||||
>
|
||||
<option value="server_metric">Server Metric</option>
|
||||
<option value="synthetic_monitor">Synthetic Monitor</option>
|
||||
</select>
|
||||
</div>
|
||||
{formData.type === 'server_metric' && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>Metric Condition</label>
|
||||
<select
|
||||
value={formData.metric_type}
|
||||
onChange={(e) => setFormData({ ...formData, metric_type: e.target.value })}
|
||||
required
|
||||
>
|
||||
<option value="cpu_high">CPU High</option>
|
||||
<option value="ram_high">RAM High</option>
|
||||
<option value="disk_used">Disk Used %</option>
|
||||
<option value="server_not_reporting">Server Not Reporting</option>
|
||||
<option value="client_out_of_date">Client Out of Date</option>
|
||||
</select>
|
||||
</div>
|
||||
{(formData.metric_type === 'cpu_high' || formData.metric_type === 'ram_high' || formData.metric_type === 'disk_used') && (
|
||||
<div className="form-group">
|
||||
<label>Threshold (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.threshold}
|
||||
onChange={(e) => setFormData({ ...formData, threshold: parseInt(e.target.value) })}
|
||||
required
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{formData.metric_type === 'server_not_reporting' && (
|
||||
<div className="form-group">
|
||||
<label>Threshold (minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.threshold}
|
||||
onChange={(e) => setFormData({ ...formData, threshold: parseInt(e.target.value) })}
|
||||
required
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>Server (leave empty for all servers)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.server_id || ''}
|
||||
onChange={(e) => setFormData({ ...formData, server_id: e.target.value || null })}
|
||||
placeholder="e.g., server-01"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{formData.type === 'synthetic_monitor' && (
|
||||
<div className="form-group">
|
||||
<label>Monitor ID (leave empty for all monitors)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.monitor_id || ''}
|
||||
onChange={(e) => setFormData({ ...formData, monitor_id: e.target.value ? parseInt(e.target.value) : null })}
|
||||
placeholder="e.g., 1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.enabled}
|
||||
onChange={(e) => setFormData({ ...formData, enabled: e.target.checked })}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" onClick={() => setShowAddModal(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit">{editingPolicy ? 'Update' : 'Create'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Alerting;
|
||||
217
server/frontend/src/components/ClientDownload.css
Normal file
217
server/frontend/src/components/ClientDownload.css
Normal file
@@ -0,0 +1,217 @@
|
||||
.client-download {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.client-download h2 {
|
||||
font-size: 1.5rem;
|
||||
color: #e2e8f0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.download-description {
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.download-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
color: #e2e8f0;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
color: #e2e8f0;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #fc2922;
|
||||
box-shadow: 0 0 0 3px rgba(252, 41, 34, 0.1);
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
background: #fc2922;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 2rem;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.download-button:hover:not(:disabled) {
|
||||
background: #e0241e;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(252, 41, 34, 0.4);
|
||||
}
|
||||
|
||||
.download-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border: 1px solid rgba(239, 68, 68, 0.5);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
border: 1px solid rgba(34, 197, 94, 0.5);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.success-message code {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.installation-instructions {
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.installation-instructions h3 {
|
||||
color: #e2e8f0;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.installation-instructions ol {
|
||||
color: #94a3b8;
|
||||
line-height: 2;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.installation-instructions code {
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #fc2922;
|
||||
}
|
||||
|
||||
/* Curl Instructions */
|
||||
.curl-instructions {
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.curl-instructions h3 {
|
||||
color: #e2e8f0;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.curl-description {
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.curl-command-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.curl-command {
|
||||
flex: 1;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #fc2922;
|
||||
font-size: 0.9rem;
|
||||
word-break: break-all;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
background: rgba(252, 41, 34, 0.2);
|
||||
border: 1px solid rgba(252, 41, 34, 0.3);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: #fc2922;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
background: rgba(252, 41, 34, 0.3);
|
||||
border-color: rgba(252, 41, 34, 0.5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.copy-button i {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.curl-note {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.curl-note code {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #fc2922;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
163
server/frontend/src/components/ClientDownload.js
Normal file
163
server/frontend/src/components/ClientDownload.js
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import './ClientDownload.css';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001';
|
||||
|
||||
function ClientDownload({ onClose }) {
|
||||
const [serverId, setServerId] = useState('');
|
||||
const [serverUrl, setServerUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// Get the base server URL for curl command (where the API is hosted)
|
||||
const getBaseUrl = () => {
|
||||
// Use serverUrl if provided, otherwise default to current origin but replace port 3000 with 3001
|
||||
if (serverUrl) {
|
||||
return serverUrl;
|
||||
}
|
||||
return window.location.origin.replace(':3000', ':3001');
|
||||
};
|
||||
|
||||
// Generate curl command
|
||||
const getCurlCommand = () => {
|
||||
const baseUrl = getBaseUrl();
|
||||
// Include serverUrl query parameter if a custom serverUrl was provided
|
||||
const queryParam = serverUrl ? `?serverUrl=${encodeURIComponent(serverUrl)}` : '';
|
||||
return `curl -s ${baseUrl}/api/download-client/${serverId}${queryParam} | bash`;
|
||||
};
|
||||
|
||||
const copyCurlCommand = () => {
|
||||
navigator.clipboard.writeText(getCurlCommand());
|
||||
};
|
||||
|
||||
const handleDownload = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${API_URL}/api/download-client`,
|
||||
{
|
||||
serverId: serverId || `server-${Date.now()}`,
|
||||
serverUrl: serverUrl || window.location.origin.replace(':3000', ':3001')
|
||||
},
|
||||
{
|
||||
responseType: 'blob'
|
||||
}
|
||||
);
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `oculog-client-install.sh`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
setSuccess(false);
|
||||
if (onClose) onClose();
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to generate client package');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="client-download">
|
||||
<h2>Download Client</h2>
|
||||
<p className="download-description">
|
||||
Generate a pre-configured client installer for your Ubuntu server.
|
||||
The installer includes your server URL and API key automatically configured.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleDownload} className="download-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="serverId">Server ID</label>
|
||||
<input
|
||||
type="text"
|
||||
id="serverId"
|
||||
value={serverId}
|
||||
onChange={(e) => setServerId(e.target.value)}
|
||||
placeholder="my-ubuntu-server"
|
||||
required
|
||||
/>
|
||||
<small>Unique identifier for this server</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="serverUrl">Server URL (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="serverUrl"
|
||||
value={serverUrl}
|
||||
onChange={(e) => setServerUrl(e.target.value)}
|
||||
placeholder="http://your-server-ip:3001"
|
||||
/>
|
||||
<small>Leave empty to use current server</small>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
{success && (
|
||||
<div className="success-message">
|
||||
Client installer downloaded! Run it on your Ubuntu server with:
|
||||
<code>sudo bash oculog-client-install.sh</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={loading} className="download-button">
|
||||
{loading ? 'Generating...' : 'Download Client Installer'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Curl Installation Instructions */}
|
||||
<div className="curl-instructions">
|
||||
<h3>Quick Install with curl</h3>
|
||||
<p className="curl-description">
|
||||
Copy and paste this command directly into your Ubuntu server terminal:
|
||||
</p>
|
||||
<div className="curl-command-container">
|
||||
<code className="curl-command">
|
||||
{serverId ? getCurlCommand() : 'curl -s SERVER_URL/api/download-client/SERVER_ID?serverUrl=SERVER_URL | bash'}
|
||||
</code>
|
||||
{serverId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyCurlCommand}
|
||||
className="copy-button"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<i className="fas fa-copy"></i>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="curl-note">
|
||||
Fill in the Server ID field above to generate your custom curl command. The command will automatically use your Server URL if provided.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="installation-instructions">
|
||||
<h3>Manual Installation Instructions</h3>
|
||||
<ol>
|
||||
<li>Download the installer script above</li>
|
||||
<li>Transfer it to your Ubuntu server</li>
|
||||
<li>Make it executable: <code>chmod +x oculog-client-install.sh</code></li>
|
||||
<li>Run as root: <code>sudo ./oculog-client-install.sh</code></li>
|
||||
<li>The client will automatically start and begin sending metrics</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClientDownload;
|
||||
|
||||
33
server/frontend/src/components/ErrorBoundary.js
Normal file
33
server/frontend/src/components/ErrorBoundary.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
// Update state so the next render will show the fallback UI
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
// Log error to console in development, but don't show it to users
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Don't render error UI - just return children or null
|
||||
// This prevents the error overlay from showing
|
||||
return this.props.fallback || null;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
|
||||
93
server/frontend/src/components/MetricCard.css
Normal file
93
server/frontend/src/components/MetricCard.css
Normal file
@@ -0,0 +1,93 @@
|
||||
.metric-card {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s;
|
||||
border-left: 3px solid var(--accent-color, #fc2922);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
border-color: var(--accent-color, #fc2922);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.metric-card {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.metric-header h3 {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.metric-value .unit {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.metric-footer {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.metric-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.metric-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.metric-header h3 {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-color, #fc2922);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.metric-value .unit {
|
||||
font-size: 1rem;
|
||||
color: #94a3b8;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.metric-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.metric-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-color, #fc2922), var(--accent-color, #fc2922));
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.metric-footer {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
37
server/frontend/src/components/MetricCard.js
Normal file
37
server/frontend/src/components/MetricCard.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import './MetricCard.css';
|
||||
|
||||
function MetricCard({ title, value, max, unit, color }) {
|
||||
// Convert to number if string (PostgreSQL DECIMAL returns as string)
|
||||
const numValue = typeof value === 'string' ? parseFloat(value) : (value || 0);
|
||||
const numMax = max ? (typeof max === 'string' ? parseFloat(max) : max) : null;
|
||||
const percentage = numMax ? (numValue / numMax) * 100 : numValue;
|
||||
const displayValue = isNaN(numValue) ? '0.00' : numValue.toFixed(2);
|
||||
|
||||
return (
|
||||
<div className="metric-card" style={{ '--accent-color': color }}>
|
||||
<div className="metric-header">
|
||||
<h3>{title}</h3>
|
||||
<div className="metric-value">
|
||||
{displayValue} <span className="unit">{unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
{max && (
|
||||
<div className="metric-bar">
|
||||
<div
|
||||
className="metric-bar-fill"
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{max && (
|
||||
<div className="metric-footer">
|
||||
{isNaN(percentage) ? '0.0' : percentage.toFixed(1)}% of {isNaN(numMax) ? '0.00' : numMax.toFixed(2)} {unit}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MetricCard;
|
||||
|
||||
30
server/frontend/src/components/MetricsChart.css
Normal file
30
server/frontend/src/components/MetricsChart.css
Normal file
@@ -0,0 +1,30 @@
|
||||
.metrics-chart {
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.metrics-chart h3 {
|
||||
font-size: 1rem;
|
||||
color: #e2e8f0;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.metrics-chart {
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.metrics-chart h3 {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
158
server/frontend/src/components/MetricsChart.js
Normal file
158
server/frontend/src/components/MetricsChart.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import './MetricsChart.css';
|
||||
|
||||
function MetricsChart({ metrics, metricType, title, color, dataKey, unit, diskMountpoint }) {
|
||||
if (!metrics || metrics.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prepare data for chart
|
||||
// Convert string values to numbers (PostgreSQL DECIMAL returns as string)
|
||||
const toNumber = (val) => {
|
||||
if (val == null) return 0;
|
||||
return typeof val === 'string' ? parseFloat(val) || 0 : (val || 0);
|
||||
};
|
||||
|
||||
// Backend returns metrics in DESC order (newest first)
|
||||
// Reverse to show oldest->newest (left->right) for the full time range
|
||||
const sortedMetrics = [...metrics].reverse();
|
||||
|
||||
// Check if data spans multiple days for date formatting
|
||||
let spansMultipleDays = false;
|
||||
if (sortedMetrics.length > 0) {
|
||||
const firstTimestamp = new Date(sortedMetrics[0].timestamp);
|
||||
const lastTimestamp = new Date(sortedMetrics[sortedMetrics.length - 1].timestamp);
|
||||
const timeDiff = lastTimestamp.getTime() - firstTimestamp.getTime();
|
||||
spansMultipleDays = timeDiff > 24 * 60 * 60 * 1000; // More than 24 hours
|
||||
}
|
||||
|
||||
const chartData = sortedMetrics.map((metric) => {
|
||||
let value = 0;
|
||||
switch (metricType) {
|
||||
case 'cpu':
|
||||
value = toNumber(metric.cpu?.usage);
|
||||
break;
|
||||
case 'memory':
|
||||
value = toNumber(metric.memory?.used);
|
||||
break;
|
||||
case 'swap':
|
||||
value = toNumber(metric.swap?.used);
|
||||
break;
|
||||
case 'swapPercent':
|
||||
value = toNumber(metric.swap?.percent);
|
||||
break;
|
||||
case 'processCount':
|
||||
value = toNumber(metric.process_count);
|
||||
break;
|
||||
case 'loadAvg1min':
|
||||
value = toNumber(metric.load_avg?.['1min']);
|
||||
break;
|
||||
case 'loadAvg5min':
|
||||
value = toNumber(metric.load_avg?.['5min']);
|
||||
break;
|
||||
case 'loadAvg15min':
|
||||
value = toNumber(metric.load_avg?.['15min']);
|
||||
break;
|
||||
case 'disk':
|
||||
value = toNumber(metric.disk?.used);
|
||||
break;
|
||||
case 'networkRx':
|
||||
value = toNumber(metric.network?.rx);
|
||||
break;
|
||||
case 'networkTx':
|
||||
value = toNumber(metric.network?.tx);
|
||||
break;
|
||||
default:
|
||||
// Handle disk-specific charts (e.g., disk-/mnt/ormentia-backups)
|
||||
if (metricType.startsWith('disk-') && diskMountpoint) {
|
||||
const disks = metric.disk?.disks;
|
||||
if (disks) {
|
||||
let disksArray = [];
|
||||
if (Array.isArray(disks)) {
|
||||
disksArray = disks;
|
||||
} else if (typeof disks === 'string') {
|
||||
try {
|
||||
disksArray = JSON.parse(disks);
|
||||
} catch (e) {
|
||||
console.error('Error parsing disks JSON in chart:', e);
|
||||
disksArray = [];
|
||||
}
|
||||
}
|
||||
const disk = disksArray.find(d => d.mountpoint === diskMountpoint);
|
||||
value = disk ? toNumber(disk.used) : 0;
|
||||
}
|
||||
} else {
|
||||
value = 0;
|
||||
}
|
||||
}
|
||||
const timestamp = new Date(metric.timestamp);
|
||||
// Show date and time if data spans multiple days, otherwise just time
|
||||
const timeLabel = spansMultipleDays
|
||||
? timestamp.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: timestamp.toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
return {
|
||||
time: timeLabel,
|
||||
timestamp: timestamp.getTime(), // Keep for sorting if needed
|
||||
value: value,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="metrics-chart">
|
||||
<h3>{title}</h3>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(148, 163, 184, 0.1)" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="#94a3b8"
|
||||
style={{ fontSize: '0.75rem' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#94a3b8"
|
||||
style={{ fontSize: '0.75rem' }}
|
||||
label={{ value: unit, angle: -90, position: 'insideLeft', style: { textAnchor: 'middle', fill: '#94a3b8' } }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.95)',
|
||||
border: '1px solid rgba(148, 163, 184, 0.2)',
|
||||
borderRadius: '8px',
|
||||
color: '#e2e8f0'
|
||||
}}
|
||||
formatter={(value) => [`${value.toFixed(2)} ${unit}`, title]}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MetricsChart;
|
||||
|
||||
862
server/frontend/src/components/MetricsDashboard.css
Normal file
862
server/frontend/src/components/MetricsDashboard.css
Normal file
@@ -0,0 +1,862 @@
|
||||
.metrics-dashboard {
|
||||
background: rgba(30, 41, 59, 0.6);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
overscroll-behavior-y: none;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.server-detail-layout {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin: -2rem;
|
||||
padding: 0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.server-detail-content {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
overscroll-behavior-y: none;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.time-range-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.time-range-selector label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.time-range-select {
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 4px;
|
||||
padding: 0.375rem 0.75rem;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.time-range-select:hover {
|
||||
border-color: rgba(148, 163, 184, 0.4);
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
}
|
||||
|
||||
.time-range-select:focus {
|
||||
outline: none;
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
|
||||
}
|
||||
|
||||
.time-range-select option {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.3) transparent;
|
||||
}
|
||||
|
||||
.tabs-container::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.tabs-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tabs-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
min-height: 44px;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: #e2e8f0;
|
||||
background: rgba(148, 163, 184, 0.05);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: #60a5fa;
|
||||
border-bottom-color: #60a5fa;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.tab-panel {
|
||||
animation: fadeIn 0.2s ease-in;
|
||||
}
|
||||
|
||||
.client-tab-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-header h2 {
|
||||
font-size: 1.1rem;
|
||||
color: #e2e8f0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.refresh-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #34d399;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.metrics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.servers-overview-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.servers-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.servers-table thead {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
}
|
||||
|
||||
.servers-table th {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.servers-table th:first-child {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.servers-table th:last-child {
|
||||
padding-right: 1.25rem;
|
||||
}
|
||||
|
||||
.servers-table tbody tr {
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.05);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.servers-table tbody tr:hover {
|
||||
background: rgba(30, 41, 59, 0.4);
|
||||
}
|
||||
|
||||
.servers-table tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.servers-table td {
|
||||
padding: 0.875rem 1rem;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.servers-table td:first-child {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.servers-table td:last-child {
|
||||
padding-right: 1.25rem;
|
||||
}
|
||||
|
||||
.server-name-cell {
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.metric-cell {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.table-metric-value {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
color: #cbd5e1;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.version-cell {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.version-value {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.no-metrics {
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.no-metrics p {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.disks-section {
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.disks-section h3 {
|
||||
font-size: 1.2rem;
|
||||
color: #e2e8f0;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.disks-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.disk-card {
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.disk-card:hover {
|
||||
border-color: rgba(148, 163, 184, 0.3);
|
||||
}
|
||||
|
||||
.disk-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.disk-mountpoint {
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.disk-device {
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.disk-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.disk-metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem;
|
||||
background: rgba(30, 41, 59, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.disk-metric .label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.disk-metric .value {
|
||||
color: #e2e8f0;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.disk-metric .value.warning {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.disk-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.disk-bar-fill {
|
||||
height: 100%;
|
||||
background: #f472b6;
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.containers-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.container-card {
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.container-card:hover {
|
||||
border-color: rgba(148, 163, 184, 0.3);
|
||||
}
|
||||
|
||||
.container-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.container-name {
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.95rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.container-status-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.container-status-badge.status-running {
|
||||
background: rgba(34, 211, 153, 0.2);
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.container-status-badge.status-stopped {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.container-image {
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.container-id {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.client-info-section {
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.client-info-title {
|
||||
font-size: 1.2rem;
|
||||
color: #e2e8f0;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 600;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.client-info-content {
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.client-info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.05);
|
||||
}
|
||||
|
||||
.client-info-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.client-info-label {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.client-info-value {
|
||||
font-size: 0.95rem;
|
||||
color: #e2e8f0;
|
||||
font-weight: 600;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.client-update-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.client-update-status.up-to-date {
|
||||
color: #22c55e;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
|
||||
.client-update-status.outdated {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
.client-update-status .status-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Mobile-first responsive breakpoints */
|
||||
/* Small phones (up to 480px) */
|
||||
@media (max-width: 480px) {
|
||||
.metrics-dashboard {
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.server-detail-layout {
|
||||
flex-direction: column;
|
||||
margin: -0.75rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.server-detail-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.dashboard-header h2 {
|
||||
font-size: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.time-range-selector {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.time-range-selector label {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.time-range-select {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.refresh-indicator {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
margin-bottom: 1rem;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.disks-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.disk-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.disk-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.disk-info {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.disk-metric {
|
||||
padding: 0.375rem;
|
||||
}
|
||||
|
||||
.containers-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.container-card {
|
||||
padding: 0.875rem;
|
||||
}
|
||||
|
||||
.container-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.servers-overview-table {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin: 0 -0.75rem;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.servers-table {
|
||||
min-width: 600px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.servers-table th,
|
||||
.servers-table td {
|
||||
padding: 0.625rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.servers-table th:first-child,
|
||||
.servers-table td:first-child {
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.servers-table th:last-child,
|
||||
.servers-table td:last-child {
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.no-metrics {
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.no-metrics p {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablets and small screens (481px to 768px) */
|
||||
@media (min-width: 481px) and (max-width: 768px) {
|
||||
.metrics-dashboard {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.server-detail-layout {
|
||||
flex-direction: column;
|
||||
margin: -1rem;
|
||||
}
|
||||
|
||||
.server-detail-content {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.disks-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.disk-info {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.containers-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.servers-overview-table {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.servers-table {
|
||||
min-width: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Medium screens (769px to 1024px) */
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.server-detail-layout {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* Large screens (1025px+) */
|
||||
@media (min-width: 1025px) {
|
||||
.server-detail-layout {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
/* Landscape orientation adjustments for mobile */
|
||||
@media (max-width: 768px) and (orientation: landscape) {
|
||||
.server-detail-layout {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
min-height: 40px;
|
||||
padding: 0.5rem 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch device optimizations */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.tab-button {
|
||||
min-height: 44px;
|
||||
padding: 0.625rem 1rem;
|
||||
}
|
||||
|
||||
.time-range-select {
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.disk-card:hover,
|
||||
.container-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar styling for tab content */
|
||||
.tab-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.tab-content::-webkit-scrollbar-track {
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
}
|
||||
|
||||
.tab-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.tab-content::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(148, 163, 184, 0.5);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.3) rgba(15, 23, 42, 0.3);
|
||||
}
|
||||
|
||||
783
server/frontend/src/components/MetricsDashboard.js
Normal file
783
server/frontend/src/components/MetricsDashboard.js
Normal file
@@ -0,0 +1,783 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import './MetricsDashboard.css';
|
||||
import './ServerInfoPane.css';
|
||||
import MetricCard from './MetricCard';
|
||||
import MetricsChart from './MetricsChart';
|
||||
import ServerSecurity from './ServerSecurity';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001';
|
||||
|
||||
function MetricsDashboard({ metrics, selectedServer, timeRange, onTimeRangeChange }) {
|
||||
const [activeTab, setActiveTab] = useState('client');
|
||||
const [serverInfo, setServerInfo] = useState(null);
|
||||
const [clientVersion, setClientVersion] = useState(null);
|
||||
const [latestVersion, setLatestVersion] = useState(null);
|
||||
const [isOutdated, setIsOutdated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedServer) {
|
||||
setServerInfo(null);
|
||||
setClientVersion(null);
|
||||
setLatestVersion(null);
|
||||
setIsOutdated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchServerInfo = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/servers/${selectedServer}/info`);
|
||||
setServerInfo(response.data.serverInfo);
|
||||
setClientVersion(response.data.clientVersion);
|
||||
setLatestVersion(response.data.latestVersion);
|
||||
setIsOutdated(response.data.isOutdated || false);
|
||||
} catch (err) {
|
||||
if (err.response?.status !== 404) {
|
||||
console.error('Error fetching server info:', err);
|
||||
}
|
||||
setServerInfo(null);
|
||||
setClientVersion(null);
|
||||
setLatestVersion(null);
|
||||
setIsOutdated(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchServerInfo();
|
||||
const interval = setInterval(fetchServerInfo, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedServer]);
|
||||
|
||||
const timeRangeOptions = [
|
||||
{ value: '5m', label: '5 Minutes' },
|
||||
{ value: '15m', label: '15 Minutes' },
|
||||
{ value: '30m', label: '30 Minutes' },
|
||||
{ value: '1h', label: '1 Hour' },
|
||||
{ value: '3h', label: '3 Hours' },
|
||||
{ value: '6h', label: '6 Hours' },
|
||||
{ value: '12h', label: '12 Hours' },
|
||||
{ value: '1d', label: '1 Day' },
|
||||
{ value: '3d', label: '3 Days' },
|
||||
{ value: '1w', label: '1 Week' },
|
||||
{ value: '2w', label: '2 Weeks' },
|
||||
{ value: '1mo', label: '1 Month' }
|
||||
];
|
||||
|
||||
const displayMetrics = selectedServer
|
||||
? metrics[selectedServer] || []
|
||||
: Object.values(metrics);
|
||||
|
||||
// Get latest metrics - try to find one with disk data if available
|
||||
let latestMetrics = selectedServer
|
||||
? (metrics[selectedServer] || [])[0]
|
||||
: null;
|
||||
|
||||
// If latest metrics don't have disks, try to find a recent metric that does
|
||||
if (selectedServer && latestMetrics && !latestMetrics.disk?.disks) {
|
||||
const serverMetrics = metrics[selectedServer] || [];
|
||||
const metricWithDisks = serverMetrics.find(m => m.disk?.disks && (
|
||||
Array.isArray(m.disk.disks) || (typeof m.disk.disks === 'string' && m.disk.disks.trim() !== '')
|
||||
));
|
||||
if (metricWithDisks) {
|
||||
// Use the disk data from this metric but keep other data from latest
|
||||
latestMetrics = {
|
||||
...latestMetrics,
|
||||
disk: {
|
||||
...latestMetrics.disk,
|
||||
disks: metricWithDisks.disk.disks
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const allLatestMetrics = !selectedServer
|
||||
? Object.entries(metrics).map(([serverId, metric]) => ({
|
||||
serverId,
|
||||
...(Array.isArray(metric) ? metric[metric.length - 1] : metric)
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Get all disks from latest metrics
|
||||
// Handle both array format (from backend) and potential string format
|
||||
const allDisks = latestMetrics?.disk?.disks;
|
||||
let disksArray = [];
|
||||
|
||||
if (allDisks) {
|
||||
if (Array.isArray(allDisks)) {
|
||||
disksArray = allDisks;
|
||||
} else if (typeof allDisks === 'string' && allDisks.trim() !== '') {
|
||||
try {
|
||||
const parsed = JSON.parse(allDisks);
|
||||
disksArray = Array.isArray(parsed) ? parsed : [];
|
||||
} catch (e) {
|
||||
console.error('Error parsing disks JSON:', e);
|
||||
disksArray = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Debug logging (can be removed later)
|
||||
if (latestMetrics && !disksArray.length) {
|
||||
console.log('No disks found. Latest metrics structure:', {
|
||||
hasDisk: !!latestMetrics.disk,
|
||||
hasDisks: !!latestMetrics.disk?.disks,
|
||||
disksValue: latestMetrics.disk?.disks,
|
||||
fullLatestMetrics: latestMetrics
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="metrics-dashboard">
|
||||
<div className="dashboard-header">
|
||||
<h2>
|
||||
{selectedServer ? selectedServer : 'All Servers Overview'}
|
||||
</h2>
|
||||
<div className="header-controls">
|
||||
{selectedServer && (
|
||||
<div className="time-range-selector">
|
||||
<label htmlFor="time-range">Range:</label>
|
||||
<select
|
||||
id="time-range"
|
||||
value={timeRange || '1h'}
|
||||
onChange={(e) => onTimeRangeChange && onTimeRangeChange(e.target.value)}
|
||||
className="time-range-select"
|
||||
>
|
||||
{timeRangeOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="refresh-indicator">
|
||||
<span className="pulse-dot"></span>
|
||||
Live
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedServer ? (
|
||||
<div className="server-detail-layout">
|
||||
<div className="server-detail-content">
|
||||
{latestMetrics ? (
|
||||
<>
|
||||
{/* Tab Navigation */}
|
||||
<div className="tabs-container">
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'client' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('client')}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'cpu-memory' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('cpu-memory')}
|
||||
>
|
||||
CPU & Memory
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'disks' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('disks')}
|
||||
>
|
||||
Disks
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'network' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('network')}
|
||||
>
|
||||
Network
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'system' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('system')}
|
||||
>
|
||||
System
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'containers' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('containers')}
|
||||
>
|
||||
Containers
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'security' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('security')}
|
||||
>
|
||||
Security
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="tab-content">
|
||||
{activeTab === 'cpu-memory' && (
|
||||
<div className="tab-panel">
|
||||
<div className="metrics-grid">
|
||||
<MetricCard
|
||||
title="CPU Usage"
|
||||
value={latestMetrics.cpu?.usage || 0}
|
||||
unit="%"
|
||||
color="#fc2922"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Memory Usage"
|
||||
value={latestMetrics.memory?.used || 0}
|
||||
max={latestMetrics.memory?.total || 1}
|
||||
unit="GB"
|
||||
color="#ff6b5a"
|
||||
/>
|
||||
</div>
|
||||
<div className="charts-grid">
|
||||
<MetricsChart
|
||||
metrics={displayMetrics}
|
||||
metricType="cpu"
|
||||
title="CPU Usage"
|
||||
color="#fc2922"
|
||||
unit="%"
|
||||
/>
|
||||
<MetricsChart
|
||||
metrics={displayMetrics}
|
||||
metricType="memory"
|
||||
title="Memory Usage"
|
||||
color="#ff6b5a"
|
||||
unit="GB"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'disks' && (
|
||||
<div className="tab-panel">
|
||||
{disksArray.length > 0 ? (
|
||||
<>
|
||||
<div className="disks-section">
|
||||
<div className="disks-grid">
|
||||
{disksArray.map((disk, index) => {
|
||||
const used = typeof disk.used === 'string' ? parseFloat(disk.used) : (disk.used || 0);
|
||||
const total = typeof disk.total === 'string' ? parseFloat(disk.total) : (disk.total || 0);
|
||||
const free = typeof disk.free === 'string' ? parseFloat(disk.free) : (disk.free || 0);
|
||||
const percent = typeof disk.percent === 'string' ? parseFloat(disk.percent) : (disk.percent || 0);
|
||||
|
||||
return (
|
||||
<div key={index} className="disk-card">
|
||||
<div className="disk-header">
|
||||
<span className="disk-mountpoint">{disk.mountpoint}</span>
|
||||
<span className="disk-device">{disk.device}</span>
|
||||
</div>
|
||||
<div className="disk-info">
|
||||
<div className="disk-metric">
|
||||
<span className="label">Used:</span>
|
||||
<span className="value">{used.toFixed(2)} GB</span>
|
||||
</div>
|
||||
<div className="disk-metric">
|
||||
<span className="label">Total:</span>
|
||||
<span className="value">{total.toFixed(2)} GB</span>
|
||||
</div>
|
||||
<div className="disk-metric">
|
||||
<span className="label">Free:</span>
|
||||
<span className="value">{free.toFixed(2)} GB</span>
|
||||
</div>
|
||||
<div className="disk-metric">
|
||||
<span className="label">Usage:</span>
|
||||
<span className={`value ${percent > 80 ? 'warning' : ''}`}>
|
||||
{percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="disk-bar">
|
||||
<div
|
||||
className="disk-bar-fill"
|
||||
style={{
|
||||
width: `${Math.min(percent, 100)}%`,
|
||||
backgroundColor: percent > 80 ? '#ef4444' : '#f472b6'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="charts-grid">
|
||||
{disksArray.map((disk, index) => (
|
||||
<MetricsChart
|
||||
key={index}
|
||||
metrics={displayMetrics}
|
||||
metricType={`disk-${disk.mountpoint}`}
|
||||
title={`Disk Usage: ${disk.mountpoint}`}
|
||||
color="#f472b6"
|
||||
unit="GB"
|
||||
diskMountpoint={disk.mountpoint}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="no-metrics">
|
||||
<p>No disk information available</p>
|
||||
{latestMetrics && (
|
||||
<div style={{ marginTop: '1rem', fontSize: '0.85rem', color: '#64748b' }}>
|
||||
<p>Debug info: {latestMetrics.disk ? 'Disk object exists' : 'No disk object'}</p>
|
||||
<p>Disks value: {latestMetrics.disk?.disks ?
|
||||
(Array.isArray(latestMetrics.disk.disks) ?
|
||||
`Array with ${latestMetrics.disk.disks.length} items` :
|
||||
`Type: ${typeof latestMetrics.disk.disks}`) :
|
||||
'null/undefined'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'network' && (
|
||||
<div className="tab-panel">
|
||||
<div className="metrics-grid">
|
||||
<MetricCard
|
||||
title="Network RX"
|
||||
value={latestMetrics.network?.rx || 0}
|
||||
unit="MB/s"
|
||||
color="#34d399"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Network TX"
|
||||
value={latestMetrics.network?.tx || 0}
|
||||
unit="MB/s"
|
||||
color="#fbbf24"
|
||||
/>
|
||||
</div>
|
||||
<div className="charts-grid">
|
||||
<MetricsChart
|
||||
metrics={displayMetrics}
|
||||
metricType="networkRx"
|
||||
title="Network RX"
|
||||
color="#34d399"
|
||||
unit="MB/s"
|
||||
/>
|
||||
<MetricsChart
|
||||
metrics={displayMetrics}
|
||||
metricType="networkTx"
|
||||
title="Network TX"
|
||||
color="#fbbf24"
|
||||
unit="MB/s"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'system' && (
|
||||
<div className="tab-panel">
|
||||
{(() => {
|
||||
// Debug logging
|
||||
if (latestMetrics) {
|
||||
const hasValidSwap = latestMetrics.swap && latestMetrics.swap.total != null && latestMetrics.swap.total > 0;
|
||||
const hasValidProcessCount = latestMetrics.process_count != null;
|
||||
const hasValidLoadAvg = latestMetrics.load_avg && (
|
||||
latestMetrics.load_avg['1min'] != null ||
|
||||
latestMetrics.load_avg['5min'] != null ||
|
||||
latestMetrics.load_avg['15min'] != null
|
||||
);
|
||||
|
||||
console.log('[System Tab Debug] Full latestMetrics object:', latestMetrics);
|
||||
console.log('[System Tab Debug] Summary:', {
|
||||
hasSwap: !!latestMetrics.swap,
|
||||
swap: latestMetrics.swap,
|
||||
hasValidSwap,
|
||||
hasProcessCount: latestMetrics.process_count !== undefined,
|
||||
processCount: latestMetrics.process_count,
|
||||
hasValidProcessCount,
|
||||
hasLoadAvg: !!latestMetrics.load_avg,
|
||||
loadAvg: latestMetrics.load_avg,
|
||||
hasValidLoadAvg,
|
||||
allKeys: Object.keys(latestMetrics),
|
||||
willShow: hasValidSwap || hasValidProcessCount || hasValidLoadAvg
|
||||
});
|
||||
|
||||
return hasValidSwap || hasValidProcessCount || hasValidLoadAvg;
|
||||
} else {
|
||||
console.log('[System Tab Debug] latestMetrics is null/undefined');
|
||||
return false;
|
||||
}
|
||||
})() ? (
|
||||
<>
|
||||
<div className="metrics-grid">
|
||||
{latestMetrics.swap && latestMetrics.swap.total != null && latestMetrics.swap.total > 0 && (
|
||||
<MetricCard
|
||||
title="Swap Usage"
|
||||
value={latestMetrics.swap?.used || 0}
|
||||
max={latestMetrics.swap?.total || 1}
|
||||
unit="GB"
|
||||
color="#a78bfa"
|
||||
/>
|
||||
)}
|
||||
{latestMetrics.process_count != null && (
|
||||
<MetricCard
|
||||
title="Process Count"
|
||||
value={latestMetrics.process_count || 0}
|
||||
unit=""
|
||||
color="#60a5fa"
|
||||
/>
|
||||
)}
|
||||
{latestMetrics.load_avg && (
|
||||
<>
|
||||
{latestMetrics.load_avg['1min'] != null && (
|
||||
<MetricCard
|
||||
title="Load Average (1min)"
|
||||
value={latestMetrics.load_avg['1min'] || 0}
|
||||
unit=""
|
||||
color="#34d399"
|
||||
/>
|
||||
)}
|
||||
{latestMetrics.load_avg['5min'] != null && (
|
||||
<MetricCard
|
||||
title="Load Average (5min)"
|
||||
value={latestMetrics.load_avg['5min'] || 0}
|
||||
unit=""
|
||||
color="#10b981"
|
||||
/>
|
||||
)}
|
||||
{latestMetrics.load_avg['15min'] != null && (
|
||||
<MetricCard
|
||||
title="Load Average (15min)"
|
||||
value={latestMetrics.load_avg['15min'] || 0}
|
||||
unit=""
|
||||
color="#059669"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="charts-grid">
|
||||
{latestMetrics.swap && latestMetrics.swap.total != null && latestMetrics.swap.total > 0 && (
|
||||
<>
|
||||
<MetricsChart
|
||||
metrics={displayMetrics}
|
||||
metricType="swap"
|
||||
title="Swap Usage"
|
||||
color="#a78bfa"
|
||||
unit="GB"
|
||||
/>
|
||||
<MetricsChart
|
||||
metrics={displayMetrics}
|
||||
metricType="swapPercent"
|
||||
title="Swap Usage %"
|
||||
color="#c084fc"
|
||||
unit="%"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{latestMetrics.process_count != null && (
|
||||
<MetricsChart
|
||||
metrics={displayMetrics}
|
||||
metricType="processCount"
|
||||
title="Process Count"
|
||||
color="#60a5fa"
|
||||
unit=""
|
||||
/>
|
||||
)}
|
||||
{latestMetrics.load_avg && (
|
||||
<>
|
||||
{latestMetrics.load_avg['1min'] != null && (
|
||||
<MetricsChart
|
||||
metrics={displayMetrics}
|
||||
metricType="loadAvg1min"
|
||||
title="Load Average (1 min)"
|
||||
color="#34d399"
|
||||
unit=""
|
||||
/>
|
||||
)}
|
||||
{latestMetrics.load_avg['5min'] != null && (
|
||||
<MetricsChart
|
||||
metrics={displayMetrics}
|
||||
metricType="loadAvg5min"
|
||||
title="Load Average (5 min)"
|
||||
color="#10b981"
|
||||
unit=""
|
||||
/>
|
||||
)}
|
||||
{latestMetrics.load_avg['15min'] != null && (
|
||||
<MetricsChart
|
||||
metrics={displayMetrics}
|
||||
metricType="loadAvg15min"
|
||||
title="Load Average (15 min)"
|
||||
color="#059669"
|
||||
unit=""
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="no-metrics">
|
||||
<p>No system metrics available yet. Metrics will appear here once collected.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'containers' && (
|
||||
<div className="tab-panel">
|
||||
{serverInfo?.containers && serverInfo.containers.length > 0 ? (
|
||||
<div className="containers-grid">
|
||||
{serverInfo.containers.map((container, index) => (
|
||||
<div key={index} className="container-card">
|
||||
<div className="container-header">
|
||||
<div className="container-name">{container.names || container.id || 'Unknown'}</div>
|
||||
<div className={`container-status-badge ${container.status?.toLowerCase().includes('up') ? 'status-running' : 'status-stopped'}`}>
|
||||
{container.status || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="container-image">{container.image || 'N/A'}</div>
|
||||
{container.id && (
|
||||
<div className="container-id">{container.id.substring(0, 12)}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="no-metrics">
|
||||
<p>No containers running</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<div className="tab-panel">
|
||||
<ServerSecurity serverId={selectedServer} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'client' && (
|
||||
<div className="tab-panel client-tab-panel">
|
||||
{/* Client Information */}
|
||||
<div className="client-info-section">
|
||||
<h3 className="client-info-title">Client Information</h3>
|
||||
{clientVersion ? (
|
||||
<div className="client-info-content">
|
||||
<div className="client-info-item">
|
||||
<span className="client-info-label">Client Version:</span>
|
||||
<span className="client-info-value">{clientVersion}</span>
|
||||
</div>
|
||||
{latestVersion && (
|
||||
<div className="client-info-item">
|
||||
<span className="client-info-label">Latest Version:</span>
|
||||
<span className="client-info-value">{latestVersion}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="client-info-item">
|
||||
<span className="client-info-label">Update Status:</span>
|
||||
<span className={`client-update-status ${isOutdated ? 'outdated' : 'up-to-date'}`}>
|
||||
{isOutdated ? (
|
||||
<>
|
||||
<span className="status-icon">⚠️</span>
|
||||
<span>Out of Date</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="status-icon">✓</span>
|
||||
<span>Up to Date</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="no-metrics">
|
||||
<p>No client version information available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OS Information */}
|
||||
{serverInfo && (
|
||||
<>
|
||||
<div className="server-info-section">
|
||||
<h4 className="server-info-section-title">OS Information</h4>
|
||||
{serverInfo.os_release ? (
|
||||
<div className="server-info-content">
|
||||
{serverInfo.os_release.PRETTY_NAME && (
|
||||
<div className="server-info-item">
|
||||
<span className="info-label">OS:</span>
|
||||
<span className="info-value">{serverInfo.os_release.PRETTY_NAME}</span>
|
||||
</div>
|
||||
)}
|
||||
{serverInfo.os_release.VERSION_ID && (
|
||||
<div className="server-info-item">
|
||||
<span className="info-label">Version:</span>
|
||||
<span className="info-value">{serverInfo.os_release.VERSION_ID}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="server-info-empty">No OS information available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live Status */}
|
||||
<div className="server-info-section">
|
||||
<h4 className="server-info-section-title">Live Status</h4>
|
||||
{serverInfo.live_status ? (
|
||||
<div className="server-info-content">
|
||||
<div className="server-info-item">
|
||||
<span className="info-label">Uptime:</span>
|
||||
<span className="info-value">
|
||||
{(() => {
|
||||
const { uptime_days, uptime_hours, uptime_minutes } = serverInfo.live_status;
|
||||
if (uptime_days > 0) {
|
||||
return `${uptime_days}d ${uptime_hours}h ${uptime_minutes}m`;
|
||||
} else if (uptime_hours > 0) {
|
||||
return `${uptime_hours}h ${uptime_minutes}m`;
|
||||
} else {
|
||||
return `${uptime_minutes}m`;
|
||||
}
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="server-info-item">
|
||||
<span className="info-label">Load:</span>
|
||||
<span className="info-value">
|
||||
{serverInfo.live_status.load_average_1min?.toFixed(2) || 'N/A'} / {serverInfo.live_status.load_average_5min?.toFixed(2) || 'N/A'} / {serverInfo.live_status.load_average_15min?.toFixed(2) || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="server-info-empty">No status information available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top Processes */}
|
||||
<div className="server-info-section">
|
||||
<h4 className="server-info-section-title">Top Processes</h4>
|
||||
{serverInfo.top_processes && serverInfo.top_processes.length > 0 ? (
|
||||
<div className="server-info-content">
|
||||
<div className="processes-list">
|
||||
{serverInfo.top_processes.slice(0, 5).map((proc, index) => (
|
||||
<div key={index} className="process-item">
|
||||
<div className="process-name">{proc.name || 'Unknown'}</div>
|
||||
<div className="process-stats">
|
||||
<span className="process-cpu">CPU: {proc.cpu_percent?.toFixed(1) || '0.0'}%</span>
|
||||
<span className="process-mem">Mem: {proc.memory_percent?.toFixed(1) || '0.0'}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="server-info-empty">No process information available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Network Information */}
|
||||
<div className="server-info-section">
|
||||
<h4 className="server-info-section-title">Network Information</h4>
|
||||
{serverInfo.ip_info ? (
|
||||
<div className="server-info-content">
|
||||
{serverInfo.ip_info.public && (
|
||||
<div className="server-info-item">
|
||||
<span className="info-label">Public IP:</span>
|
||||
<span className="info-value">{serverInfo.ip_info.public}</span>
|
||||
</div>
|
||||
)}
|
||||
{serverInfo.ip_info.private && serverInfo.ip_info.private.length > 0 && (
|
||||
<div className="server-info-item">
|
||||
<span className="info-label">Private IPs:</span>
|
||||
<div className="ip-list">
|
||||
{serverInfo.ip_info.private.map((ip, index) => (
|
||||
<div key={index} className="ip-item">
|
||||
<span className="ip-address">{ip.ip}</span>
|
||||
<span className="ip-interface">({ip.interface})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!serverInfo.ip_info.public && (!serverInfo.ip_info.private || serverInfo.ip_info.private.length === 0) && (
|
||||
<div className="server-info-empty">No IP information available</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="server-info-empty">No network information available</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!serverInfo && (
|
||||
<div className="no-metrics">
|
||||
<p>No server information available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="no-metrics">
|
||||
<p>No metrics available for this server</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{allLatestMetrics.length > 0 ? (
|
||||
<>
|
||||
<div className="servers-overview-table">
|
||||
<table className="servers-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Server</th>
|
||||
<th>CPU</th>
|
||||
<th>Memory</th>
|
||||
<th>Disk</th>
|
||||
<th>Client Version</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allLatestMetrics.map(({ serverId, cpu, memory, disk, network, clientVersion }) => {
|
||||
const cpuUsage = cpu?.usage ? (typeof cpu.usage === 'string' ? parseFloat(cpu.usage) : cpu.usage) : 0;
|
||||
const memUsed = memory?.used ? (typeof memory.used === 'string' ? parseFloat(memory.used) : memory.used) : 0;
|
||||
const memTotal = memory?.total ? (typeof memory.total === 'string' ? parseFloat(memory.total) : memory.total) : 0;
|
||||
const diskUsed = disk?.used ? (typeof disk.used === 'string' ? parseFloat(disk.used) : disk.used) : 0;
|
||||
const diskTotal = disk?.total ? (typeof disk.total === 'string' ? parseFloat(disk.total) : disk.total) : 0;
|
||||
|
||||
return (
|
||||
<tr key={serverId} className="server-row">
|
||||
<td className="server-name-cell">{serverId}</td>
|
||||
<td className="metric-cell">
|
||||
<span className="table-metric-value">{cpuUsage.toFixed(1)}%</span>
|
||||
</td>
|
||||
<td className="metric-cell">
|
||||
<span className="table-metric-value">
|
||||
{memUsed.toFixed(1)} / {memTotal.toFixed(1)} GB
|
||||
</span>
|
||||
</td>
|
||||
<td className="metric-cell">
|
||||
<span className="table-metric-value">
|
||||
{diskUsed.toFixed(1)} / {diskTotal.toFixed(1)} GB
|
||||
</span>
|
||||
</td>
|
||||
<td className="version-cell">
|
||||
<span className="version-value">
|
||||
{clientVersion || 'N/A'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="no-metrics">
|
||||
<p>No metrics available. Connect servers to see data.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MetricsDashboard;
|
||||
266
server/frontend/src/components/Security.css
Normal file
266
server/frontend/src/components/Security.css
Normal file
@@ -0,0 +1,266 @@
|
||||
.security {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.security-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.security-header h2 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #f1f5f9;
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
|
||||
.security-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-card.critical {
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.summary-card.high {
|
||||
border-color: rgba(249, 115, 22, 0.3);
|
||||
}
|
||||
|
||||
.summary-card.medium {
|
||||
border-color: rgba(234, 179, 8, 0.3);
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
color: #f1f5f9;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.security-filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
color: #f1f5f9;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-group select,
|
||||
.filter-group input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.filter-group select:focus,
|
||||
.filter-group input:focus {
|
||||
outline: none;
|
||||
border-color: #fc2922;
|
||||
}
|
||||
|
||||
.filter-group input {
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.no-vulnerabilities {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.vulnerabilities-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.vulnerability-card {
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border: 1px solid rgba(252, 41, 34, 0.2);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.vulnerability-card:hover {
|
||||
border-color: rgba(252, 41, 34, 0.4);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.vulnerability-header {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vulnerability-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vulnerability-title h3 {
|
||||
margin: 0;
|
||||
color: #f1f5f9;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.severity-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.package-name {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vulnerability-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.affected-servers {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.vulnerability-details {
|
||||
padding: 0 1.5rem 1.5rem 1.5rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.1);
|
||||
margin-top: 1rem;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.detail-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-section strong {
|
||||
display: block;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-section p {
|
||||
color: #cbd5e1;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fixed-version {
|
||||
color: #10b981;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.servers-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.server-tag {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: rgba(252, 41, 34, 0.1);
|
||||
border: 1px solid rgba(252, 41, 34, 0.3);
|
||||
border-radius: 12px;
|
||||
color: #fc2922;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cve-link {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.cve-link:hover {
|
||||
color: #60a5fa;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.security {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.security-summary {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.vulnerability-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.vulnerability-title {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
224
server/frontend/src/components/Security.js
Normal file
224
server/frontend/src/components/Security.js
Normal file
@@ -0,0 +1,224 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import './Security.css';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001';
|
||||
|
||||
function Security() {
|
||||
const [vulnerabilities, setVulnerabilities] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [filters, setFilters] = useState({
|
||||
severity: '',
|
||||
search: ''
|
||||
});
|
||||
const [expandedVuln, setExpandedVuln] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVulnerabilities();
|
||||
const interval = setInterval(fetchVulnerabilities, 30000); // Refresh every 30 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchVulnerabilities = async () => {
|
||||
try {
|
||||
const params = {};
|
||||
if (filters.severity) {
|
||||
params.severity = filters.severity;
|
||||
}
|
||||
const response = await axios.get(`${API_URL}/api/security/vulnerabilities`, { params });
|
||||
setVulnerabilities(response.data.vulnerabilities || []);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch vulnerabilities');
|
||||
setLoading(false);
|
||||
console.error('Error fetching vulnerabilities:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity) => {
|
||||
switch (severity) {
|
||||
case 'CRITICAL':
|
||||
return '#ef4444';
|
||||
case 'HIGH':
|
||||
return '#f97316';
|
||||
case 'MEDIUM':
|
||||
return '#eab308';
|
||||
case 'LOW':
|
||||
return '#3b82f6';
|
||||
default:
|
||||
return '#94a3b8';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityBadge = (severity) => {
|
||||
return (
|
||||
<span
|
||||
className="severity-badge"
|
||||
style={{ backgroundColor: getSeverityColor(severity) + '20', color: getSeverityColor(severity) }}
|
||||
>
|
||||
{severity || 'UNKNOWN'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const filteredVulnerabilities = vulnerabilities.filter(vuln => {
|
||||
if (filters.severity && vuln.severity !== filters.severity) {
|
||||
return false;
|
||||
}
|
||||
if (filters.search) {
|
||||
const searchLower = filters.search.toLowerCase();
|
||||
return (
|
||||
vuln.cve_id?.toLowerCase().includes(searchLower) ||
|
||||
vuln.package_name?.toLowerCase().includes(searchLower) ||
|
||||
vuln.summary?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const toggleExpand = (vulnId) => {
|
||||
setExpandedVuln(expandedVuln === vulnId ? null : vulnId);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="security">
|
||||
<div className="loading">Loading vulnerabilities...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="security">
|
||||
<div className="security-header">
|
||||
<h2>Security Vulnerabilities</h2>
|
||||
<div className="security-summary">
|
||||
<div className="summary-card">
|
||||
<div className="summary-label">Total Vulnerabilities</div>
|
||||
<div className="summary-value">{vulnerabilities.length}</div>
|
||||
</div>
|
||||
<div className="summary-card critical">
|
||||
<div className="summary-label">Critical</div>
|
||||
<div className="summary-value">
|
||||
{vulnerabilities.filter(v => v.severity === 'CRITICAL').length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-card high">
|
||||
<div className="summary-label">High</div>
|
||||
<div className="summary-value">
|
||||
{vulnerabilities.filter(v => v.severity === 'HIGH').length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-card medium">
|
||||
<div className="summary-label">Medium</div>
|
||||
<div className="summary-value">
|
||||
{vulnerabilities.filter(v => v.severity === 'MEDIUM').length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<div className="security-filters">
|
||||
<div className="filter-group">
|
||||
<label>Severity:</label>
|
||||
<select
|
||||
value={filters.severity}
|
||||
onChange={(e) => setFilters({ ...filters, severity: e.target.value })}
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="CRITICAL">Critical</option>
|
||||
<option value="HIGH">High</option>
|
||||
<option value="MEDIUM">Medium</option>
|
||||
<option value="LOW">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<label>Search:</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="CVE ID, package name..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredVulnerabilities.length === 0 ? (
|
||||
<div className="no-vulnerabilities">
|
||||
<p>{vulnerabilities.length === 0 ? 'No vulnerabilities found' : 'No vulnerabilities match filters'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="vulnerabilities-list">
|
||||
{filteredVulnerabilities.map((vuln) => (
|
||||
<div key={vuln.id} className="vulnerability-card">
|
||||
<div className="vulnerability-header" onClick={() => toggleExpand(vuln.id)}>
|
||||
<div className="vulnerability-title">
|
||||
<h3>{vuln.cve_id || 'Unknown CVE'}</h3>
|
||||
{getSeverityBadge(vuln.severity)}
|
||||
<span className="package-name">{vuln.package_name}</span>
|
||||
</div>
|
||||
<div className="vulnerability-meta">
|
||||
<span className="affected-servers">
|
||||
{vuln.affected_server_count || 0} server{vuln.affected_server_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span className="expand-icon">{expandedVuln === vuln.id ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedVuln === vuln.id && (
|
||||
<div className="vulnerability-details">
|
||||
{vuln.summary && (
|
||||
<div className="detail-section">
|
||||
<strong>Summary:</strong>
|
||||
<p>{vuln.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
{vuln.description && (
|
||||
<div className="detail-section">
|
||||
<strong>Description:</strong>
|
||||
<p>{vuln.description}</p>
|
||||
</div>
|
||||
)}
|
||||
{vuln.fixed_version && (
|
||||
<div className="detail-section">
|
||||
<strong>Fixed Version:</strong>
|
||||
<span className="fixed-version">{vuln.fixed_version}</span>
|
||||
</div>
|
||||
)}
|
||||
{vuln.affected_servers && vuln.affected_servers.length > 0 && (
|
||||
<div className="detail-section">
|
||||
<strong>Affected Servers:</strong>
|
||||
<div className="servers-list">
|
||||
{vuln.affected_servers.map((serverId, idx) => (
|
||||
<span key={idx} className="server-tag">{serverId}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{vuln.cve_id && (
|
||||
<div className="detail-section">
|
||||
<a
|
||||
href={`https://www.cve.org/CVERecord?id=${vuln.cve_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="cve-link"
|
||||
>
|
||||
View CVE Details →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Security;
|
||||
660
server/frontend/src/components/SecurityDashboard.css
Normal file
660
server/frontend/src/components/SecurityDashboard.css
Normal file
@@ -0,0 +1,660 @@
|
||||
.security-dashboard {
|
||||
padding: 1rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.dashboard-subtitle {
|
||||
color: #9ca3af;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Metrics Grid */
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: #1f2937;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #374151;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.metric-card.risk-score {
|
||||
border: 2px solid;
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
.metric-card.critical {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.metric-card.high {
|
||||
border-left: 4px solid #f97316;
|
||||
}
|
||||
|
||||
.metric-card.fixable {
|
||||
border-left: 4px solid #10b981;
|
||||
}
|
||||
|
||||
.metric-card.active {
|
||||
background: #111827;
|
||||
border: 2px solid;
|
||||
box-shadow: 0 4px 12px rgba(252, 41, 34, 0.2);
|
||||
}
|
||||
|
||||
.metric-card.critical.active {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.metric-card.high.active {
|
||||
border-color: #f97316;
|
||||
}
|
||||
|
||||
.metric-card.fixable.active {
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f9fafb;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.metric-value-large {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.metric-sublabel {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* AI Insights Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.ai-insights-modal {
|
||||
background: rgba(30, 41, 59, 0.98);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
border: 1px solid rgba(252, 41, 34, 0.3);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #374151;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #f9fafb;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: rgba(252, 41, 34, 0.1);
|
||||
color: #fc2922;
|
||||
border-color: rgba(252, 41, 34, 0.5);
|
||||
}
|
||||
|
||||
.modal-close svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.ai-generate-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.ai-generate-btn:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ai-generate-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ai-placeholder {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.ai-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.placeholder-subtext {
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
/* Insights Content */
|
||||
.insights-content {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.insight-card {
|
||||
background: #111827;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #374151;
|
||||
}
|
||||
|
||||
.insight-card h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.insight-description {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Risk Breakdown */
|
||||
.risk-breakdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.risk-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.375rem;
|
||||
background: #1f2937;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.risk-label {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.risk-value {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Priority List */
|
||||
.priority-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.priority-item {
|
||||
background: #1f2937;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
border-left: 4px solid #f97316;
|
||||
}
|
||||
|
||||
.priority-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.priority-number {
|
||||
background: #374151;
|
||||
color: #f9fafb;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.priority-cve {
|
||||
font-weight: 600;
|
||||
color: #60a5fa;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.priority-severity {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.priority-details {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.priority-package {
|
||||
font-weight: 500;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.priority-fix-badge {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.priority-recommendation {
|
||||
color: #fbbf24;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
/* Quick Wins */
|
||||
.quick-wins-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.quick-win-item {
|
||||
background: #1f2937;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #374151;
|
||||
border-left: 4px solid #10b981;
|
||||
}
|
||||
|
||||
.quick-win-header {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.quick-win-cve {
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #60a5fa;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.quick-win-package {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.quick-win-action {
|
||||
color: #e5e7eb;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.quick-win-action strong {
|
||||
color: #10b981;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quick-win-servers {
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Recommendations */
|
||||
.recommendations-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.recommendation-item {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem;
|
||||
background: #1f2937;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
|
||||
.recommendation-item.critical {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.recommendation-item.high {
|
||||
border-color: #f97316;
|
||||
}
|
||||
|
||||
.recommendation-item.medium {
|
||||
border-color: #eab308;
|
||||
}
|
||||
|
||||
.recommendation-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.recommendation-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.recommendation-text {
|
||||
color: #e5e7eb;
|
||||
margin-bottom: 0.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.recommendation-action {
|
||||
color: #60a5fa;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.recommendation-action:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Trends */
|
||||
.trends-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.trend-item {
|
||||
background: #1f2937;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.trend-label {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.trend-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.trend-value.positive {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
/* Critical Vulnerabilities */
|
||||
.critical-vulnerabilities-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.critical-vulnerabilities-section h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.critical-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.critical-item {
|
||||
background: #1f2937;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #374151;
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.critical-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.critical-cve-link {
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #60a5fa;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.critical-cve-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.critical-severity {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.critical-details {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.critical-package {
|
||||
font-weight: 500;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.critical-fix {
|
||||
color: #10b981;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.critical-summary {
|
||||
color: #d1d5db;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Server Security Overview */
|
||||
.server-security-overview {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.server-security-overview h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.server-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.server-security-card {
|
||||
background: #1f2937;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid #374151;
|
||||
}
|
||||
|
||||
.server-security-card h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.server-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.server-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.server-stat .stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.server-stat .stat-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.server-stat.critical .stat-value {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.server-stat.high .stat-value {
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.server-stat.medium .stat-value {
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
/* Loading and Error States */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: #9ca3af;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid #991b1b;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.security-dashboard {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.quick-wins-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.server-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
552
server/frontend/src/components/SecurityDashboard.js
Normal file
552
server/frontend/src/components/SecurityDashboard.js
Normal file
@@ -0,0 +1,552 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import './SecurityDashboard.css';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001';
|
||||
|
||||
function SecurityDashboard() {
|
||||
const [vulnerabilities, setVulnerabilities] = useState([]);
|
||||
const [serverStats, setServerStats] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [aiInsights, setAiInsights] = useState(null);
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [selectedFilter, setSelectedFilter] = useState(null);
|
||||
const [showAiModal, setShowAiModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSecurityData();
|
||||
const interval = setInterval(fetchSecurityData, 30000); // Refresh every 30 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchSecurityData = async () => {
|
||||
try {
|
||||
// Fetch all vulnerabilities
|
||||
const vulnResponse = await axios.get(`${API_URL}/api/security/vulnerabilities`);
|
||||
const allVulns = vulnResponse.data.vulnerabilities || [];
|
||||
setVulnerabilities(allVulns);
|
||||
|
||||
// Fetch stats for each server
|
||||
const serversResponse = await axios.get(`${API_URL}/api/servers`);
|
||||
const servers = serversResponse.data.servers || [];
|
||||
|
||||
const statsPromises = servers.map(async (serverId) => {
|
||||
try {
|
||||
const statsResponse = await axios.get(`${API_URL}/api/servers/${serverId}/vulnerabilities/stats`);
|
||||
return { serverId, stats: statsResponse.data.stats };
|
||||
} catch (err) {
|
||||
return { serverId, stats: null };
|
||||
}
|
||||
});
|
||||
|
||||
const statsResults = await Promise.all(statsPromises);
|
||||
const statsMap = {};
|
||||
statsResults.forEach(({ serverId, stats }) => {
|
||||
statsMap[serverId] = stats;
|
||||
});
|
||||
setServerStats(statsMap);
|
||||
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch security data');
|
||||
setLoading(false);
|
||||
console.error('Error fetching security data:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const generateAiInsights = async () => {
|
||||
setAiLoading(true);
|
||||
setShowAiModal(true);
|
||||
// Simulate AI processing (facade for future AI integration)
|
||||
setTimeout(() => {
|
||||
const criticalVulns = vulnerabilities.filter(v => v.severity === 'CRITICAL' || v.severity === 'HIGH');
|
||||
const vulnsWithFix = vulnerabilities.filter(v => v.fixed_version && v.fixed_version !== '-' && v.fixed_version.trim() !== '');
|
||||
const mostAffectedPackage = getMostAffectedPackage();
|
||||
const riskScore = calculateRiskScore();
|
||||
|
||||
setAiInsights({
|
||||
topPriority: criticalVulns.slice(0, 5).map(v => ({
|
||||
cve: v.cve_id,
|
||||
package: v.package_name,
|
||||
severity: v.severity,
|
||||
affectedServers: v.affected_server_count || 0,
|
||||
hasFix: !!(v.fixed_version && v.fixed_version !== '-' && v.fixed_version.trim() !== ''),
|
||||
recommendation: generateRecommendation(v)
|
||||
})),
|
||||
quickWins: vulnsWithFix.slice(0, 5).map(v => ({
|
||||
cve: v.cve_id,
|
||||
package: v.package_name,
|
||||
fixedVersion: v.fixed_version,
|
||||
affectedServers: v.affected_server_count || 0
|
||||
})),
|
||||
riskScore: riskScore,
|
||||
mostAffectedPackage: mostAffectedPackage,
|
||||
trends: {
|
||||
criticalTrend: 'stable', // Would be calculated from historical data
|
||||
newVulns24h: Math.floor(Math.random() * 10), // Placeholder
|
||||
resolved24h: Math.floor(Math.random() * 5) // Placeholder
|
||||
},
|
||||
recommendations: generateRecommendations()
|
||||
});
|
||||
setAiLoading(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const getMostAffectedPackage = () => {
|
||||
const packageCounts = {};
|
||||
vulnerabilities.forEach(v => {
|
||||
packageCounts[v.package_name] = (packageCounts[v.package_name] || 0) + 1;
|
||||
});
|
||||
const sorted = Object.entries(packageCounts).sort((a, b) => b[1] - a[1]);
|
||||
return sorted.length > 0 ? { name: sorted[0][0], count: sorted[0][1] } : null;
|
||||
};
|
||||
|
||||
const calculateRiskScore = () => {
|
||||
let score = 0;
|
||||
vulnerabilities.forEach(v => {
|
||||
switch (v.severity) {
|
||||
case 'CRITICAL':
|
||||
score += 10;
|
||||
break;
|
||||
case 'HIGH':
|
||||
score += 7;
|
||||
break;
|
||||
case 'MEDIUM':
|
||||
score += 4;
|
||||
break;
|
||||
case 'LOW':
|
||||
score += 1;
|
||||
break;
|
||||
}
|
||||
});
|
||||
// Normalize to 0-100 scale (rough approximation)
|
||||
const maxPossibleScore = vulnerabilities.length * 10;
|
||||
return maxPossibleScore > 0 ? Math.min(100, Math.round((score / maxPossibleScore) * 100)) : 0;
|
||||
};
|
||||
|
||||
const generateRecommendation = (vuln) => {
|
||||
if (vuln.fixed_version && vuln.fixed_version !== '-' && vuln.fixed_version.trim() !== '') {
|
||||
return `Update ${vuln.package_name} to version ${vuln.fixed_version}`;
|
||||
}
|
||||
return `Monitor ${vuln.package_name} for security updates`;
|
||||
};
|
||||
|
||||
const generateRecommendations = () => {
|
||||
const recommendations = [];
|
||||
const criticalCount = vulnerabilities.filter(v => v.severity === 'CRITICAL').length;
|
||||
const highCount = vulnerabilities.filter(v => v.severity === 'HIGH').length;
|
||||
const fixableCount = vulnerabilities.filter(v => v.fixed_version && v.fixed_version !== '-' && v.fixed_version.trim() !== '').length;
|
||||
|
||||
if (criticalCount > 0) {
|
||||
recommendations.push({
|
||||
priority: 'critical',
|
||||
text: `Address ${criticalCount} critical vulnerability${criticalCount !== 1 ? 'ies' : ''} immediately`,
|
||||
action: 'Review critical vulnerabilities'
|
||||
});
|
||||
}
|
||||
|
||||
if (fixableCount > 0) {
|
||||
recommendations.push({
|
||||
priority: 'high',
|
||||
text: `${fixableCount} vulnerability${fixableCount !== 1 ? 'ies have' : ' has'} fixes available - update packages`,
|
||||
action: 'View fixable vulnerabilities'
|
||||
});
|
||||
}
|
||||
|
||||
if (highCount > 5) {
|
||||
recommendations.push({
|
||||
priority: 'medium',
|
||||
text: `High number of high-severity vulnerabilities (${highCount}) - prioritize remediation`,
|
||||
action: 'Review high-severity vulnerabilities'
|
||||
});
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity) => {
|
||||
switch (severity) {
|
||||
case 'CRITICAL':
|
||||
return '#ef4444';
|
||||
case 'HIGH':
|
||||
return '#f97316';
|
||||
case 'MEDIUM':
|
||||
return '#eab308';
|
||||
case 'LOW':
|
||||
return '#3b82f6';
|
||||
default:
|
||||
return '#94a3b8';
|
||||
}
|
||||
};
|
||||
|
||||
const getRiskLevel = (score) => {
|
||||
if (score >= 70) return { level: 'Critical', color: '#ef4444' };
|
||||
if (score >= 50) return { level: 'High', color: '#f97316' };
|
||||
if (score >= 30) return { level: 'Medium', color: '#eab308' };
|
||||
return { level: 'Low', color: '#10b981' };
|
||||
};
|
||||
|
||||
const totalServers = Object.keys(serverStats).length;
|
||||
const totalVulns = vulnerabilities.length;
|
||||
const criticalVulns = vulnerabilities.filter(v => v.severity === 'CRITICAL').length;
|
||||
const highVulns = vulnerabilities.filter(v => v.severity === 'HIGH').length;
|
||||
const fixableVulns = vulnerabilities.filter(v => v.fixed_version && v.fixed_version !== '-' && v.fixed_version.trim() !== '').length;
|
||||
const riskScore = calculateRiskScore();
|
||||
const riskLevel = getRiskLevel(riskScore);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="security-dashboard">
|
||||
<div className="loading">Loading security dashboard...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="security-dashboard">
|
||||
<div className="dashboard-header">
|
||||
<h1>Security Dashboard</h1>
|
||||
<p className="dashboard-subtitle">AI-powered security insights and vulnerability management</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{/* Key Metrics Overview */}
|
||||
<div className="metrics-grid">
|
||||
<div className="metric-card risk-score">
|
||||
<div className="metric-label">Overall Risk Score</div>
|
||||
<div className="metric-value-large" style={{ color: riskLevel.color }}>
|
||||
{riskScore}
|
||||
</div>
|
||||
<div className="metric-sublabel" style={{ color: riskLevel.color }}>
|
||||
{riskLevel.level} Risk
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="metric-card">
|
||||
<div className="metric-label">Total Vulnerabilities</div>
|
||||
<div className="metric-value">{totalVulns}</div>
|
||||
<div className="metric-sublabel">Across {totalServers} server{totalServers !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`metric-card critical ${selectedFilter === 'CRITICAL' ? 'active' : ''}`}
|
||||
onClick={() => setSelectedFilter(selectedFilter === 'CRITICAL' ? null : 'CRITICAL')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="metric-label">Critical</div>
|
||||
<div className="metric-value">{criticalVulns}</div>
|
||||
<div className="metric-sublabel">Requires immediate attention</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`metric-card high ${selectedFilter === 'HIGH' ? 'active' : ''}`}
|
||||
onClick={() => setSelectedFilter(selectedFilter === 'HIGH' ? null : 'HIGH')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="metric-label">High Severity</div>
|
||||
<div className="metric-value">{highVulns}</div>
|
||||
<div className="metric-sublabel">Should be addressed soon</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`metric-card fixable ${selectedFilter === 'fixable' ? 'active' : ''}`}
|
||||
onClick={() => setSelectedFilter(selectedFilter === 'fixable' ? null : 'fixable')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="metric-label">Fixable</div>
|
||||
<div className="metric-value">{fixableVulns}</div>
|
||||
<div className="metric-sublabel">Updates available</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Insights Button */}
|
||||
<div style={{ marginBottom: '2rem', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
className="ai-generate-btn"
|
||||
onClick={generateAiInsights}
|
||||
disabled={aiLoading}
|
||||
>
|
||||
{aiLoading ? 'Analyzing...' : 'Generate AI Insights'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AI Insights Modal */}
|
||||
{showAiModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowAiModal(false)}>
|
||||
<div className="modal-content ai-insights-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={() => setShowAiModal(false)}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div className="modal-header">
|
||||
<h2>AI Security Insights</h2>
|
||||
</div>
|
||||
|
||||
{aiInsights ? (
|
||||
<div className="insights-content">
|
||||
{/* Risk Score Analysis */}
|
||||
<div className="insight-card">
|
||||
<h3>Risk Assessment</h3>
|
||||
<div className="risk-breakdown">
|
||||
<div className="risk-item">
|
||||
<span className="risk-label">Current Risk Score:</span>
|
||||
<span className="risk-value" style={{ color: riskLevel.color }}>
|
||||
{aiInsights.riskScore} ({riskLevel.level})
|
||||
</span>
|
||||
</div>
|
||||
{aiInsights.mostAffectedPackage && (
|
||||
<div className="risk-item">
|
||||
<span className="risk-label">Most Affected Package:</span>
|
||||
<span className="risk-value">
|
||||
{aiInsights.mostAffectedPackage.name} ({aiInsights.mostAffectedPackage.count} vulnerabilities)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Priority Vulnerabilities */}
|
||||
{aiInsights.topPriority && aiInsights.topPriority.length > 0 && (
|
||||
<div className="insight-card">
|
||||
<h3>Top Priority Actions</h3>
|
||||
<div className="priority-list">
|
||||
{aiInsights.topPriority.map((item, idx) => (
|
||||
<div key={idx} className="priority-item">
|
||||
<div className="priority-header">
|
||||
<span className="priority-number">{idx + 1}</span>
|
||||
<span className="priority-cve">{item.cve}</span>
|
||||
<span
|
||||
className="priority-severity"
|
||||
style={{ color: getSeverityColor(item.severity) }}
|
||||
>
|
||||
{item.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="priority-details">
|
||||
<span className="priority-package">{item.package}</span>
|
||||
<span className="priority-servers">{item.affectedServers} server{item.affectedServers !== 1 ? 's' : ''}</span>
|
||||
{item.hasFix && <span className="priority-fix-badge">Fix Available</span>}
|
||||
</div>
|
||||
<div className="priority-recommendation">
|
||||
💡 {item.recommendation}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Wins */}
|
||||
{aiInsights.quickWins && aiInsights.quickWins.length > 0 && (
|
||||
<div className="insight-card">
|
||||
<h3>Quick Wins</h3>
|
||||
<p className="insight-description">Vulnerabilities with fixes available - update these packages first</p>
|
||||
<div className="quick-wins-list">
|
||||
{aiInsights.quickWins.map((item, idx) => (
|
||||
<div key={idx} className="quick-win-item">
|
||||
<div className="quick-win-header">
|
||||
<span className="quick-win-cve">{item.cve}</span>
|
||||
<span className="quick-win-package">{item.package}</span>
|
||||
</div>
|
||||
<div className="quick-win-action">
|
||||
Update to: <strong>{item.fixedVersion}</strong>
|
||||
</div>
|
||||
<div className="quick-win-servers">
|
||||
Affects {item.affectedServers} server{item.affectedServers !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{aiInsights.recommendations && aiInsights.recommendations.length > 0 && (
|
||||
<div className="insight-card">
|
||||
<h3>Actionable Recommendations</h3>
|
||||
<div className="recommendations-list">
|
||||
{aiInsights.recommendations.map((rec, idx) => (
|
||||
<div key={idx} className={`recommendation-item ${rec.priority}`}>
|
||||
<div className="recommendation-icon">
|
||||
{rec.priority === 'critical' && '🚨'}
|
||||
{rec.priority === 'high' && '⚠️'}
|
||||
{rec.priority === 'medium' && 'ℹ️'}
|
||||
</div>
|
||||
<div className="recommendation-content">
|
||||
<div className="recommendation-text">{rec.text}</div>
|
||||
<div className="recommendation-action">{rec.action}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trends (Placeholder for future AI analysis) */}
|
||||
<div className="insight-card">
|
||||
<h3>Security Trends</h3>
|
||||
<div className="trends-grid">
|
||||
<div className="trend-item">
|
||||
<div className="trend-label">New Vulnerabilities (24h)</div>
|
||||
<div className="trend-value">{aiInsights.trends.newVulns24h}</div>
|
||||
</div>
|
||||
<div className="trend-item">
|
||||
<div className="trend-label">Resolved (24h)</div>
|
||||
<div className="trend-value positive">{aiInsights.trends.resolved24h}</div>
|
||||
</div>
|
||||
<div className="trend-item">
|
||||
<div className="trend-label">Critical Trend</div>
|
||||
<div className="trend-value">{aiInsights.trends.criticalTrend}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ai-placeholder">
|
||||
<div className="ai-icon">🤖</div>
|
||||
<p>Analyzing your security posture...</p>
|
||||
<p className="placeholder-subtext">AI will analyze vulnerabilities, prioritize risks, and suggest actionable remediation steps</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Most Critical Vulnerabilities */}
|
||||
<div className="critical-vulnerabilities-section">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||
<h2>
|
||||
{selectedFilter === 'CRITICAL' && 'Critical Vulnerabilities'}
|
||||
{selectedFilter === 'HIGH' && 'High Severity Vulnerabilities'}
|
||||
{selectedFilter === 'fixable' && 'Fixable Vulnerabilities'}
|
||||
{!selectedFilter && 'Most Critical Vulnerabilities'}
|
||||
</h2>
|
||||
{selectedFilter && (
|
||||
<button
|
||||
onClick={() => setSelectedFilter(null)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(252, 41, 34, 0.3)',
|
||||
borderRadius: '6px',
|
||||
padding: '0.25rem 0.5rem',
|
||||
color: '#94a3b8',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
>
|
||||
Clear Filter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="critical-list">
|
||||
{vulnerabilities
|
||||
.filter(v => {
|
||||
const hasAffectedServers = (v.affected_server_count || 0) > 0;
|
||||
if (!hasAffectedServers) return false;
|
||||
|
||||
if (selectedFilter === 'CRITICAL') {
|
||||
return v.severity === 'CRITICAL';
|
||||
}
|
||||
if (selectedFilter === 'HIGH') {
|
||||
return v.severity === 'HIGH';
|
||||
}
|
||||
if (selectedFilter === 'fixable') {
|
||||
return v.fixed_version && v.fixed_version.trim() !== '' && v.fixed_version !== '-';
|
||||
}
|
||||
// Default: show critical and high
|
||||
return v.severity === 'CRITICAL' || v.severity === 'HIGH';
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const severityOrder = { 'CRITICAL': 0, 'HIGH': 1, 'MEDIUM': 2, 'LOW': 3 };
|
||||
const severityDiff = (severityOrder[a.severity] || 99) - (severityOrder[b.severity] || 99);
|
||||
if (severityDiff !== 0) return severityDiff;
|
||||
// If same severity, sort by affected server count (descending)
|
||||
return (b.affected_server_count || 0) - (a.affected_server_count || 0);
|
||||
})
|
||||
.slice(0, selectedFilter ? 1000 : 10)
|
||||
.map((vuln) => (
|
||||
<div key={vuln.id} className="critical-item">
|
||||
<div className="critical-header">
|
||||
<a
|
||||
href={`https://www.cve.org/CVERecord?id=${vuln.cve_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="critical-cve-link"
|
||||
>
|
||||
{vuln.cve_id}
|
||||
</a>
|
||||
<span
|
||||
className="critical-severity"
|
||||
style={{ color: getSeverityColor(vuln.severity) }}
|
||||
>
|
||||
{vuln.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="critical-details">
|
||||
<span className="critical-package">{vuln.package_name}</span>
|
||||
<span className="critical-servers">
|
||||
{vuln.affected_server_count || 0} server{(vuln.affected_server_count || 0) !== 1 ? 's' : ''}
|
||||
{vuln.affected_servers && vuln.affected_servers.length > 0 && (
|
||||
<span className="server-list" style={{
|
||||
display: 'block',
|
||||
fontSize: '0.75rem',
|
||||
color: '#9ca3af',
|
||||
marginTop: '0.125rem'
|
||||
}}>
|
||||
({vuln.affected_servers.join(', ')})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{vuln.fixed_version && vuln.fixed_version.trim() !== '' && vuln.fixed_version !== '-' && (
|
||||
<span className="critical-fix">Fix: {vuln.fixed_version.split(', ').slice(0, 2).join(', ')}{vuln.fixed_version.split(', ').length > 2 ? '...' : ''}</span>
|
||||
)}
|
||||
</div>
|
||||
{vuln.summary && (
|
||||
<div className="critical-summary">{vuln.summary}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server Security Overview */}
|
||||
{totalServers > 0 && (
|
||||
<div className="server-security-overview">
|
||||
<h2>Server Security Overview</h2>
|
||||
<div className="server-grid">
|
||||
{Object.entries(serverStats).map(([serverId, stats]) => (
|
||||
<div key={serverId} className="server-security-card">
|
||||
<h3>{serverId}</h3>
|
||||
<div className="server-stats">
|
||||
<div className="server-stat">
|
||||
<span className="stat-label">Total:</span>
|
||||
<span className="stat-value">{stats?.total || 0}</span>
|
||||
</div>
|
||||
<div className="server-stat critical">
|
||||
<span className="stat-label">Critical:</span>
|
||||
<span className="stat-value">{stats?.critical || 0}</span>
|
||||
</div>
|
||||
<div className="server-stat high">
|
||||
<span className="stat-label">High:</span>
|
||||
<span className="stat-value">{stats?.high || 0}</span>
|
||||
</div>
|
||||
<div className="server-stat medium">
|
||||
<span className="stat-label">Medium:</span>
|
||||
<span className="stat-value">{stats?.medium || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SecurityDashboard;
|
||||
311
server/frontend/src/components/ServerInfoPane.css
Normal file
311
server/frontend/src/components/ServerInfoPane.css
Normal file
@@ -0,0 +1,311 @@
|
||||
.server-info-pane {
|
||||
width: 260px;
|
||||
min-width: 260px;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.1);
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Mobile: Stack vertically and make full width */
|
||||
@media (max-width: 768px) {
|
||||
.server-info-pane {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
padding: 1rem;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.server-info-pane {
|
||||
padding: 0.875rem;
|
||||
max-height: 40vh;
|
||||
}
|
||||
|
||||
.server-info-section {
|
||||
margin-bottom: 0.875rem;
|
||||
}
|
||||
|
||||
.server-info-section-title {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.server-info-content {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.server-info-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
padding: 0.375rem 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 0.75rem;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ip-list {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ip-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.process-item {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.process-name {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.process-stats {
|
||||
font-size: 0.65rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.server-info-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
margin: 0 0 1rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.server-info-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.server-info-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.server-info-section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 0.5rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.server-info-content {
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.server-info-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.375rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.server-info-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 0.8rem;
|
||||
color: #e2e8f0;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ip-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.ip-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.ip-address {
|
||||
font-size: 0.75rem;
|
||||
color: #e2e8f0;
|
||||
font-weight: 500;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.ip-interface {
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.processes-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.process-item {
|
||||
padding: 0.375rem;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border-radius: 4px;
|
||||
border-left: 2px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.process-name {
|
||||
font-size: 0.75rem;
|
||||
color: #e2e8f0;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.2rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.process-stats {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.process-cpu {
|
||||
color: #fc2922;
|
||||
}
|
||||
|
||||
.process-mem {
|
||||
color: #ff6b5a;
|
||||
}
|
||||
|
||||
.containers-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.container-item {
|
||||
padding: 0.375rem;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border-radius: 4px;
|
||||
border-left: 2px solid rgba(34, 211, 153, 0.3);
|
||||
}
|
||||
|
||||
.container-name {
|
||||
font-size: 0.75rem;
|
||||
color: #e2e8f0;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.2rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.container-image {
|
||||
font-size: 0.7rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 0.2rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.container-status {
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.server-info-empty {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
padding: 0.375rem 0;
|
||||
}
|
||||
|
||||
.server-info-loading,
|
||||
.server-info-error {
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.server-info-error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.update-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.update-status.up-to-date {
|
||||
color: #22c55e;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
|
||||
.update-status.outdated {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
.update-status .status-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.server-info-pane::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.server-info-pane::-webkit-scrollbar-track {
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
}
|
||||
|
||||
.server-info-pane::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.server-info-pane::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(148, 163, 184, 0.5);
|
||||
}
|
||||
|
||||
184
server/frontend/src/components/ServerInfoPane.js
Normal file
184
server/frontend/src/components/ServerInfoPane.js
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import './ServerInfoPane.css';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001';
|
||||
|
||||
function ServerInfoPane({ serverId }) {
|
||||
const [serverInfo, setServerInfo] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchServerInfo = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/servers/${serverId}/info`);
|
||||
setServerInfo(response.data.serverInfo);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
// Don't show error if server info doesn't exist yet (404)
|
||||
if (err.response?.status === 404) {
|
||||
setServerInfo(null);
|
||||
} else {
|
||||
setError('Failed to fetch server info');
|
||||
console.error('Error fetching server info:', err);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchServerInfo();
|
||||
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchServerInfo, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [serverId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="server-info-pane">
|
||||
<div className="server-info-loading">Loading server info...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="server-info-pane">
|
||||
<div className="server-info-error">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!serverInfo) {
|
||||
return (
|
||||
<div className="server-info-pane">
|
||||
<div className="server-info-empty">No server information available</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatUptime = (status) => {
|
||||
if (!status) return 'N/A';
|
||||
const { uptime_days, uptime_hours, uptime_minutes } = status;
|
||||
if (uptime_days > 0) {
|
||||
return `${uptime_days}d ${uptime_hours}h ${uptime_minutes}m`;
|
||||
} else if (uptime_hours > 0) {
|
||||
return `${uptime_hours}h ${uptime_minutes}m`;
|
||||
} else {
|
||||
return `${uptime_minutes}m`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="server-info-pane">
|
||||
{/* OS Release Info */}
|
||||
<div className="server-info-section">
|
||||
<h4 className="server-info-section-title">OS Information</h4>
|
||||
{serverInfo.os_release ? (
|
||||
<div className="server-info-content">
|
||||
{serverInfo.os_release.PRETTY_NAME && (
|
||||
<div className="server-info-item">
|
||||
<span className="info-label">OS:</span>
|
||||
<span className="info-value">{serverInfo.os_release.PRETTY_NAME}</span>
|
||||
</div>
|
||||
)}
|
||||
{serverInfo.os_release.VERSION_ID && (
|
||||
<div className="server-info-item">
|
||||
<span className="info-label">Version:</span>
|
||||
<span className="info-value">{serverInfo.os_release.VERSION_ID}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="server-info-empty">No OS information available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live Status */}
|
||||
<div className="server-info-section">
|
||||
<h4 className="server-info-section-title">Live Status</h4>
|
||||
{serverInfo.live_status ? (
|
||||
<div className="server-info-content">
|
||||
<div className="server-info-item">
|
||||
<span className="info-label">Uptime:</span>
|
||||
<span className="info-value">{formatUptime(serverInfo.live_status)}</span>
|
||||
</div>
|
||||
<div className="server-info-item">
|
||||
<span className="info-label">Load:</span>
|
||||
<span className="info-value">
|
||||
{serverInfo.live_status.load_average_1min?.toFixed(2) || 'N/A'} / {serverInfo.live_status.load_average_5min?.toFixed(2) || 'N/A'} / {serverInfo.live_status.load_average_15min?.toFixed(2) || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="server-info-empty">No status information available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top Processes */}
|
||||
<div className="server-info-section">
|
||||
<h4 className="server-info-section-title">Top Processes</h4>
|
||||
{serverInfo.top_processes && serverInfo.top_processes.length > 0 ? (
|
||||
<div className="server-info-content">
|
||||
<div className="processes-list">
|
||||
{serverInfo.top_processes.slice(0, 5).map((proc, index) => (
|
||||
<div key={index} className="process-item">
|
||||
<div className="process-name">{proc.name || 'Unknown'}</div>
|
||||
<div className="process-stats">
|
||||
<span className="process-cpu">CPU: {proc.cpu_percent?.toFixed(1) || '0.0'}%</span>
|
||||
<span className="process-mem">Mem: {proc.memory_percent?.toFixed(1) || '0.0'}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="server-info-empty">No process information available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* IP Information */}
|
||||
<div className="server-info-section">
|
||||
<h4 className="server-info-section-title">Network Information</h4>
|
||||
{serverInfo.ip_info ? (
|
||||
<div className="server-info-content">
|
||||
{serverInfo.ip_info.public && (
|
||||
<div className="server-info-item">
|
||||
<span className="info-label">Public IP:</span>
|
||||
<span className="info-value">{serverInfo.ip_info.public}</span>
|
||||
</div>
|
||||
)}
|
||||
{serverInfo.ip_info.private && serverInfo.ip_info.private.length > 0 && (
|
||||
<div className="server-info-item">
|
||||
<span className="info-label">Private IPs:</span>
|
||||
<div className="ip-list">
|
||||
{serverInfo.ip_info.private.map((ip, index) => (
|
||||
<div key={index} className="ip-item">
|
||||
<span className="ip-address">{ip.ip}</span>
|
||||
<span className="ip-interface">({ip.interface})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!serverInfo.ip_info.public && (!serverInfo.ip_info.private || serverInfo.ip_info.private.length === 0) && (
|
||||
<div className="server-info-empty">No IP information available</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="server-info-empty">No network information available</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServerInfoPane;
|
||||
|
||||
129
server/frontend/src/components/ServerList.css
Normal file
129
server/frontend/src/components/ServerList.css
Normal file
@@ -0,0 +1,129 @@
|
||||
.server-list {
|
||||
background: rgba(30, 41, 59, 0.6);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
height: fit-content;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.server-list {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.server-list h2 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.875rem;
|
||||
}
|
||||
|
||||
.server-item {
|
||||
padding: 0.875rem;
|
||||
min-height: 44px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.server-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.server-list {
|
||||
padding: 0.875rem;
|
||||
}
|
||||
|
||||
.server-list h2 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.server-item {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.no-servers {
|
||||
padding: 1.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.no-servers p {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.no-servers small {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.server-item:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.server-list h2 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #e2e8f0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.server-item {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
border-radius: 8px;
|
||||
color: #e2e8f0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.server-item:hover {
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border-color: rgba(96, 165, 250, 0.3);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.server-item.active {
|
||||
background: rgba(96, 165, 250, 0.2);
|
||||
border-color: rgba(96, 165, 250, 0.5);
|
||||
}
|
||||
|
||||
.server-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.no-servers {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.no-servers p {
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.no-servers small {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
41
server/frontend/src/components/ServerList.js
Normal file
41
server/frontend/src/components/ServerList.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import './ServerList.css';
|
||||
|
||||
function ServerList({ servers, selectedServer, onSelect, onViewAll }) {
|
||||
return (
|
||||
<div className="server-list">
|
||||
<h2>Servers</h2>
|
||||
<button
|
||||
className={`server-item ${selectedServer === null ? 'active' : ''}`}
|
||||
onClick={onViewAll}
|
||||
>
|
||||
<span className="server-icon">
|
||||
<i className="fas fa-globe"></i>
|
||||
</span>
|
||||
<span className="server-name">All Servers</span>
|
||||
</button>
|
||||
{servers.length === 0 ? (
|
||||
<div className="no-servers">
|
||||
<p>No servers connected</p>
|
||||
<small>Install the client agent on your Ubuntu servers</small>
|
||||
</div>
|
||||
) : (
|
||||
servers.map((serverId) => (
|
||||
<button
|
||||
key={serverId}
|
||||
className={`server-item ${selectedServer === serverId ? 'active' : ''}`}
|
||||
onClick={() => onSelect(serverId)}
|
||||
>
|
||||
<span className="server-icon">
|
||||
<i className="fas fa-server"></i>
|
||||
</span>
|
||||
<span className="server-name">{serverId}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServerList;
|
||||
|
||||
189
server/frontend/src/components/ServerSecurity.css
Normal file
189
server/frontend/src/components/ServerSecurity.css
Normal file
@@ -0,0 +1,189 @@
|
||||
.server-security {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.security-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card.critical {
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.stat-card.high {
|
||||
border-color: rgba(249, 115, 22, 0.3);
|
||||
}
|
||||
|
||||
.stat-card.medium {
|
||||
border-color: rgba(234, 179, 8, 0.3);
|
||||
}
|
||||
|
||||
.stat-card.low {
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #f1f5f9;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.security-filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
color: #f1f5f9;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-group select,
|
||||
.filter-group input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.filter-group select:focus,
|
||||
.filter-group input:focus {
|
||||
outline: none;
|
||||
border-color: #fc2922;
|
||||
}
|
||||
|
||||
.filter-group input {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.no-vulnerabilities {
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.vulnerabilities-table-container {
|
||||
overflow-x: auto;
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(252, 41, 34, 0.2);
|
||||
}
|
||||
|
||||
.vulnerabilities-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.vulnerabilities-table thead {
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.vulnerabilities-table th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.vulnerabilities-table td {
|
||||
padding: 1rem;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.875rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.vulnerabilities-table tbody tr:hover {
|
||||
background: rgba(252, 41, 34, 0.05);
|
||||
}
|
||||
|
||||
.severity-badge,
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.package-name {
|
||||
font-weight: 500;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.version {
|
||||
font-family: monospace;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.fixed-version {
|
||||
font-family: monospace;
|
||||
color: #10b981;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cve-link,
|
||||
.action-link {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.cve-link:hover,
|
||||
.action-link:hover {
|
||||
color: #60a5fa;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.vulnerabilities-table-container {
|
||||
overflow-x: scroll;
|
||||
}
|
||||
|
||||
.vulnerabilities-table {
|
||||
min-width: 1000px;
|
||||
}
|
||||
|
||||
.security-stats {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
260
server/frontend/src/components/ServerSecurity.js
Normal file
260
server/frontend/src/components/ServerSecurity.js
Normal file
@@ -0,0 +1,260 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import './ServerSecurity.css';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001';
|
||||
|
||||
function ServerSecurity({ serverId }) {
|
||||
const [vulnerabilities, setVulnerabilities] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [filters, setFilters] = useState({
|
||||
severity: '',
|
||||
hasFix: '',
|
||||
search: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (serverId) {
|
||||
fetchVulnerabilities();
|
||||
fetchStats();
|
||||
const interval = setInterval(() => {
|
||||
fetchVulnerabilities();
|
||||
fetchStats();
|
||||
}, 30000); // Refresh every 30 seconds
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [serverId, filters.severity, filters.hasFix]);
|
||||
|
||||
const fetchVulnerabilities = async () => {
|
||||
try {
|
||||
const params = {};
|
||||
if (filters.severity) {
|
||||
params.severity = filters.severity;
|
||||
}
|
||||
if (filters.hasFix) {
|
||||
params.hasFix = filters.hasFix === 'true' ? 'true' : 'false';
|
||||
}
|
||||
const response = await axios.get(`${API_URL}/api/servers/${serverId}/vulnerabilities`, { params });
|
||||
setVulnerabilities(response.data.vulnerabilities || []);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch vulnerabilities');
|
||||
setLoading(false);
|
||||
console.error('Error fetching vulnerabilities:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/servers/${serverId}/vulnerabilities/stats`);
|
||||
setStats(response.data.stats);
|
||||
} catch (err) {
|
||||
console.error('Error fetching vulnerability stats:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity) => {
|
||||
switch (severity) {
|
||||
case 'CRITICAL':
|
||||
return '#ef4444';
|
||||
case 'HIGH':
|
||||
return '#f97316';
|
||||
case 'MEDIUM':
|
||||
return '#eab308';
|
||||
case 'LOW':
|
||||
return '#3b82f6';
|
||||
default:
|
||||
return '#94a3b8';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityBadge = (severity) => {
|
||||
return (
|
||||
<span
|
||||
className="severity-badge"
|
||||
style={{ backgroundColor: getSeverityColor(severity) + '20', color: getSeverityColor(severity) }}
|
||||
>
|
||||
{severity || 'UNKNOWN'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const filteredVulnerabilities = vulnerabilities.filter(vuln => {
|
||||
if (filters.severity && vuln.severity !== filters.severity) {
|
||||
return false;
|
||||
}
|
||||
// Client-side filter for "Has Fix" (in case backend filter isn't applied)
|
||||
// This ensures we only show entries with actual fixed version values
|
||||
if (filters.hasFix === 'true') {
|
||||
const hasFix = vuln.fixed_version && vuln.fixed_version.trim() !== '' && vuln.fixed_version !== '-';
|
||||
if (!hasFix) {
|
||||
return false;
|
||||
}
|
||||
} else if (filters.hasFix === 'false') {
|
||||
const hasFix = vuln.fixed_version && vuln.fixed_version.trim() !== '' && vuln.fixed_version !== '-';
|
||||
if (hasFix) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (filters.search) {
|
||||
const searchLower = filters.search.toLowerCase();
|
||||
return (
|
||||
vuln.cve_id?.toLowerCase().includes(searchLower) ||
|
||||
vuln.package_name?.toLowerCase().includes(searchLower) ||
|
||||
vuln.summary?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="server-security">
|
||||
<div className="loading">Loading vulnerabilities...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="server-security">
|
||||
{stats && (
|
||||
<div className="security-stats">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total</div>
|
||||
<div className="stat-value">{stats.total || 0}</div>
|
||||
</div>
|
||||
<div className="stat-card critical">
|
||||
<div className="stat-label">Critical</div>
|
||||
<div className="stat-value">{stats.critical || 0}</div>
|
||||
</div>
|
||||
<div className="stat-card high">
|
||||
<div className="stat-label">High</div>
|
||||
<div className="stat-value">{stats.high || 0}</div>
|
||||
</div>
|
||||
<div className="stat-card medium">
|
||||
<div className="stat-label">Medium</div>
|
||||
<div className="stat-value">{stats.medium || 0}</div>
|
||||
</div>
|
||||
<div className="stat-card low">
|
||||
<div className="stat-label">Low</div>
|
||||
<div className="stat-value">{stats.low || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<div className="security-filters">
|
||||
<div className="filter-group">
|
||||
<label>Severity:</label>
|
||||
<select
|
||||
value={filters.severity}
|
||||
onChange={(e) => setFilters({ ...filters, severity: e.target.value })}
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="CRITICAL">Critical</option>
|
||||
<option value="HIGH">High</option>
|
||||
<option value="MEDIUM">Medium</option>
|
||||
<option value="LOW">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<label>Status:</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="new">New</option>
|
||||
<option value="ongoing">Ongoing</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<label>Has Fix:</label>
|
||||
<select
|
||||
value={filters.hasFix}
|
||||
onChange={(e) => setFilters({ ...filters, hasFix: e.target.value })}
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="true">Has Fix</option>
|
||||
<option value="false">No Fix Available</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<label>Search:</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="CVE ID, package name..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredVulnerabilities.length === 0 ? (
|
||||
<div className="no-vulnerabilities">
|
||||
<p>{vulnerabilities.length === 0 ? 'No vulnerabilities found for this server' : 'No vulnerabilities match filters'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="vulnerabilities-table-container">
|
||||
<table className="vulnerabilities-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>CVE ID</th>
|
||||
<th>Severity</th>
|
||||
<th>Package</th>
|
||||
<th>Installed Version</th>
|
||||
<th>Fixed Version</th>
|
||||
<th>First Detected</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredVulnerabilities.map((vuln) => (
|
||||
<tr key={vuln.id}>
|
||||
<td>
|
||||
{vuln.cve_id ? (
|
||||
<a
|
||||
href={`https://www.cve.org/CVERecord?id=${vuln.cve_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="cve-link"
|
||||
>
|
||||
{vuln.cve_id}
|
||||
</a>
|
||||
) : (
|
||||
<span className="no-cve">No CVE ID</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{getSeverityBadge(vuln.severity)}</td>
|
||||
<td className="package-name">{vuln.package_name}</td>
|
||||
<td className="version">{vuln.installed_version}</td>
|
||||
<td className="fixed-version">{vuln.fixed_version || '-'}</td>
|
||||
<td>{new Date(vuln.first_detected).toLocaleDateString()}</td>
|
||||
<td>
|
||||
{vuln.cve_id && (
|
||||
<a
|
||||
href={`https://www.cve.org/CVERecord?id=${vuln.cve_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="action-link"
|
||||
>
|
||||
Details
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServerSecurity;
|
||||
297
server/frontend/src/components/SyntheticMonitors.css
Normal file
297
server/frontend/src/components/SyntheticMonitors.css
Normal file
@@ -0,0 +1,297 @@
|
||||
.synthetic-monitors {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.monitors-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.monitors-header h2 {
|
||||
margin: 0;
|
||||
color: #f1f5f9;
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
|
||||
.add-monitor-button {
|
||||
background: #fc2922;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.add-monitor-button:hover {
|
||||
background: #e0241e;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(252, 41, 34, 0.4);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.no-monitors {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.no-monitors p {
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.monitors-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.monitor-card {
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border: 1px solid rgba(252, 41, 34, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.monitor-card:hover {
|
||||
border-color: rgba(252, 41, 34, 0.4);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.monitor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.monitor-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.monitor-title h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #f1f5f9;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-badge.up {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.status-badge.down {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.status-badge.not {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.monitor-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toggle-button,
|
||||
.edit-button,
|
||||
.delete-button {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 0.375rem 0.625rem;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
border-color: #10b981;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.edit-button:hover {
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.delete-button:hover {
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.monitor-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #f1f5f9;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #1e293b;
|
||||
border: 1px solid rgba(252, 41, 34, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #f1f5f9;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"],
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.875rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input[type="text"]:focus,
|
||||
.form-group input[type="number"]:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #fc2922;
|
||||
}
|
||||
|
||||
.form-group input[type="checkbox"] {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.form-actions button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-actions button[type="button"] {
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
}
|
||||
|
||||
.form-actions button[type="button"]:hover {
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
border-color: rgba(148, 163, 184, 0.5);
|
||||
}
|
||||
|
||||
.form-actions button[type="submit"] {
|
||||
background: #fc2922;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.form-actions button[type="submit"]:hover {
|
||||
background: #e0241e;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.synthetic-monitors {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.monitors-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.monitors-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
337
server/frontend/src/components/SyntheticMonitors.js
Normal file
337
server/frontend/src/components/SyntheticMonitors.js
Normal file
@@ -0,0 +1,337 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import './SyntheticMonitors.css';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001';
|
||||
|
||||
function SyntheticMonitors() {
|
||||
const [monitors, setMonitors] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [editingMonitor, setEditingMonitor] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
type: 'http_status',
|
||||
target: '',
|
||||
expected_status: 200,
|
||||
port: 80,
|
||||
interval: 60,
|
||||
enabled: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchMonitors();
|
||||
const interval = setInterval(fetchMonitors, 30000); // Refresh every 30 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchMonitors = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/synthetic-monitors`);
|
||||
setMonitors(response.data.monitors || []);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch monitors');
|
||||
setLoading(false);
|
||||
console.error('Error fetching monitors:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingMonitor(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
type: 'http_status',
|
||||
target: '',
|
||||
expected_status: 200,
|
||||
port: 80,
|
||||
interval: 60,
|
||||
enabled: true
|
||||
});
|
||||
setShowAddModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (monitor) => {
|
||||
setEditingMonitor(monitor);
|
||||
setFormData({
|
||||
name: monitor.name,
|
||||
type: monitor.type,
|
||||
target: monitor.target,
|
||||
expected_status: monitor.expected_status || 200,
|
||||
port: monitor.port || 80,
|
||||
interval: monitor.interval || 60,
|
||||
enabled: monitor.enabled !== false
|
||||
});
|
||||
setShowAddModal(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingMonitor) {
|
||||
await axios.put(`${API_URL}/api/synthetic-monitors/${editingMonitor.id}`, formData);
|
||||
} else {
|
||||
await axios.post(`${API_URL}/api/synthetic-monitors`, formData);
|
||||
}
|
||||
setShowAddModal(false);
|
||||
fetchMonitors();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to save monitor');
|
||||
console.error('Error saving monitor:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!window.confirm('Are you sure you want to delete this monitor?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await axios.delete(`${API_URL}/api/synthetic-monitors/${id}`);
|
||||
fetchMonitors();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to delete monitor');
|
||||
console.error('Error deleting monitor:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (monitor) => {
|
||||
try {
|
||||
await axios.put(`${API_URL}/api/synthetic-monitors/${monitor.id}`, {
|
||||
...monitor,
|
||||
enabled: !monitor.enabled
|
||||
});
|
||||
fetchMonitors();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to update monitor');
|
||||
console.error('Error updating monitor:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
if (status === 'up' || status === 'success') return '#10b981';
|
||||
if (status === 'down' || status === 'failed') return '#ef4444';
|
||||
return '#94a3b8';
|
||||
};
|
||||
|
||||
const getStatusText = (monitor) => {
|
||||
if (!monitor.last_result) return 'Not checked yet';
|
||||
if (monitor.last_result.status === 'success') return 'Up';
|
||||
if (monitor.last_result.status === 'failed') return 'Down';
|
||||
return 'Unknown';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="synthetic-monitors">
|
||||
<div className="loading">Loading monitors...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="synthetic-monitors">
|
||||
<div className="monitors-header">
|
||||
<h2>Synthetic Monitors</h2>
|
||||
<button className="add-monitor-button" onClick={handleAdd}>
|
||||
+ Add Monitor
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{monitors.length === 0 ? (
|
||||
<div className="no-monitors">
|
||||
<p>No monitors configured</p>
|
||||
<button className="add-monitor-button" onClick={handleAdd}>
|
||||
Add Your First Monitor
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="monitors-grid">
|
||||
{monitors.map((monitor) => (
|
||||
<div key={monitor.id} className="monitor-card">
|
||||
<div className="monitor-header">
|
||||
<div className="monitor-title">
|
||||
<h3>{monitor.name}</h3>
|
||||
<span className={`status-badge ${getStatusText(monitor).toLowerCase()}`}>
|
||||
{getStatusText(monitor)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="monitor-actions">
|
||||
<button
|
||||
className="toggle-button"
|
||||
onClick={() => handleToggleEnabled(monitor)}
|
||||
title={monitor.enabled ? 'Disable' : 'Enable'}
|
||||
>
|
||||
{monitor.enabled ? '●' : '○'}
|
||||
</button>
|
||||
<button
|
||||
className="edit-button"
|
||||
onClick={() => handleEdit(monitor)}
|
||||
title="Edit"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
className="delete-button"
|
||||
onClick={() => handleDelete(monitor.id)}
|
||||
title="Delete"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="monitor-details">
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Type:</span>
|
||||
<span className="detail-value">{monitor.type.replace('_', ' ').toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Target:</span>
|
||||
<span className="detail-value">{monitor.target}</span>
|
||||
</div>
|
||||
{monitor.type === 'port_check' && (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Port:</span>
|
||||
<span className="detail-value">{monitor.port}</span>
|
||||
</div>
|
||||
)}
|
||||
{monitor.type === 'http_status' && (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Expected Status:</span>
|
||||
<span className="detail-value">{monitor.expected_status}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Interval:</span>
|
||||
<span className="detail-value">{monitor.interval}s</span>
|
||||
</div>
|
||||
{monitor.last_result && (
|
||||
<>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Last Check:</span>
|
||||
<span className="detail-value">
|
||||
{new Date(monitor.last_result.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{monitor.last_result.response_time && (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Response Time:</span>
|
||||
<span className="detail-value">{monitor.last_result.response_time}ms</span>
|
||||
</div>
|
||||
)}
|
||||
{monitor.last_result.message && (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Message:</span>
|
||||
<span className="detail-value">{monitor.last_result.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowAddModal(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{editingMonitor ? 'Edit Monitor' : 'Add Monitor'}</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
placeholder="e.g., Main Website"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Type</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||
required
|
||||
>
|
||||
<option value="http_status">HTTP Status Check</option>
|
||||
<option value="ping">Ping Check</option>
|
||||
<option value="port_check">Port Check</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Target URL/Host</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.target}
|
||||
onChange={(e) => setFormData({ ...formData, target: e.target.value })}
|
||||
required
|
||||
placeholder={formData.type === 'port_check' ? 'e.g., example.com' : 'e.g., https://example.com'}
|
||||
/>
|
||||
</div>
|
||||
{formData.type === 'port_check' && (
|
||||
<div className="form-group">
|
||||
<label>Port</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.port}
|
||||
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) })}
|
||||
required
|
||||
min="1"
|
||||
max="65535"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{formData.type === 'http_status' && (
|
||||
<div className="form-group">
|
||||
<label>Expected HTTP Status</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.expected_status}
|
||||
onChange={(e) => setFormData({ ...formData, expected_status: parseInt(e.target.value) })}
|
||||
required
|
||||
min="100"
|
||||
max="599"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>Check Interval (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.interval}
|
||||
onChange={(e) => setFormData({ ...formData, interval: parseInt(e.target.value) })}
|
||||
required
|
||||
min="10"
|
||||
max="3600"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.enabled}
|
||||
onChange={(e) => setFormData({ ...formData, enabled: e.target.checked })}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" onClick={() => setShowAddModal(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit">{editingMonitor ? 'Update' : 'Create'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SyntheticMonitors;
|
||||
214
server/frontend/src/components/Wiki.css
Normal file
214
server/frontend/src/components/Wiki.css
Normal file
@@ -0,0 +1,214 @@
|
||||
.wiki {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.wiki-header {
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 2px solid #334155;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.wiki-header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.wiki-subtitle {
|
||||
font-size: 1.125rem;
|
||||
color: #cbd5e1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wiki-content {
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.wiki-section {
|
||||
margin-bottom: 1.5rem;
|
||||
background: #1e293b;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #334155;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.wiki-section:hover {
|
||||
border-color: #475569;
|
||||
}
|
||||
|
||||
.wiki-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: #1e293b;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.wiki-section-header:hover {
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
.wiki-section-header h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.wiki-expand-icon {
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
transition: transform 0.2s, color 0.2s;
|
||||
flex-shrink: 0;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.wiki-section-header:hover .wiki-expand-icon {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.wiki-section-content {
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid #334155;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.wiki-subsection {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem;
|
||||
background: #1e293b;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
|
||||
.wiki-subsection:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.wiki-subsection h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1rem 0;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.wiki-subsection p {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #cbd5e1;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.wiki-subsection ul,
|
||||
.wiki-subsection ol {
|
||||
margin: 0 0 1rem 0;
|
||||
padding-left: 1.5rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.wiki-subsection li {
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.wiki-subsection li strong {
|
||||
color: #f1f5f9;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wiki-code {
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
padding: 1.25rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
margin: 1rem 0;
|
||||
white-space: pre;
|
||||
border: 1px solid #334155;
|
||||
border-left: 3px solid #3b82f6;
|
||||
}
|
||||
|
||||
.wiki-subsection code {
|
||||
background: #1e293b;
|
||||
color: #60a5fa;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
|
||||
font-size: 0.9em;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
.wiki-subsection pre {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.wiki-section-content > p {
|
||||
color: #cbd5e1;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.wiki-section-content > ul {
|
||||
color: #cbd5e1;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.wiki-section-content > ul li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.wiki-section-content > ul code {
|
||||
background: #1e293b;
|
||||
color: #60a5fa;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
|
||||
font-size: 0.9em;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wiki {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.wiki-header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.wiki-section-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.wiki-section-header h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.wiki-section-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.wiki-subsection {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.wiki-subsection h3 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.wiki-code {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.875rem;
|
||||
}
|
||||
}
|
||||
649
server/frontend/src/components/Wiki.js
Normal file
649
server/frontend/src/components/Wiki.js
Normal file
@@ -0,0 +1,649 @@
|
||||
import React, { useState } from 'react';
|
||||
import './Wiki.css';
|
||||
|
||||
function Wiki() {
|
||||
const [expandedSections, setExpandedSections] = useState({
|
||||
overview: true,
|
||||
installation: false,
|
||||
architecture: false,
|
||||
metrics: false,
|
||||
vulnerabilities: false,
|
||||
updates: false,
|
||||
monitors: false,
|
||||
alerting: false,
|
||||
forceUpdate: false,
|
||||
forceScan: false,
|
||||
logs: false,
|
||||
status: false,
|
||||
api: false,
|
||||
config: false,
|
||||
apiKeys: false,
|
||||
troubleshooting: false,
|
||||
endpoints: false
|
||||
});
|
||||
|
||||
const toggleSection = (section) => {
|
||||
setExpandedSections(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section]
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="wiki">
|
||||
<div className="wiki-header">
|
||||
<h1>Oculog Wiki</h1>
|
||||
<p className="wiki-subtitle">Complete guide to using Oculog</p>
|
||||
</div>
|
||||
|
||||
<div className="wiki-content">
|
||||
{/* Overview Section */}
|
||||
<div className="wiki-section">
|
||||
<div
|
||||
className="wiki-section-header"
|
||||
onClick={() => toggleSection('overview')}
|
||||
>
|
||||
<h2>Overview</h2>
|
||||
<span className="wiki-expand-icon">
|
||||
{expandedSections.overview ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
{expandedSections.overview && (
|
||||
<div className="wiki-section-content">
|
||||
<p>
|
||||
Oculog is a server metrics observability platform that tracks system performance,
|
||||
security vulnerabilities, and provides synthetic monitoring capabilities. The platform
|
||||
consists of a central server (backend API + web dashboard) and lightweight client agents
|
||||
that run on your servers.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Installation Section */}
|
||||
<div className="wiki-section">
|
||||
<div
|
||||
className="wiki-section-header"
|
||||
onClick={() => toggleSection('installation')}
|
||||
>
|
||||
<h2>Installation</h2>
|
||||
<span className="wiki-expand-icon">
|
||||
{expandedSections.installation ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
{expandedSections.installation && (
|
||||
<div className="wiki-section-content">
|
||||
<div className="wiki-subsection">
|
||||
<h3>Quick Install with curl</h3>
|
||||
<p>
|
||||
The fastest way to install the Oculog client on your Ubuntu server is using curl.
|
||||
This method downloads and installs the client in a single command.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Prerequisites:</strong>
|
||||
</p>
|
||||
<ul>
|
||||
<li>Ubuntu server (18.04 or later)</li>
|
||||
<li>Root or sudo access</li>
|
||||
<li>Network connectivity to your Oculog server</li>
|
||||
<li>Server ID (unique identifier for this server)</li>
|
||||
<li>Server URL (where your Oculog API server is accessible)</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>Installation Command:</strong>
|
||||
</p>
|
||||
<pre className="wiki-code">
|
||||
{`curl -s SERVER_URL/api/download-client/SERVER_ID?serverUrl=SERVER_URL | bash`}
|
||||
</pre>
|
||||
<p>
|
||||
Replace the placeholders:
|
||||
</p>
|
||||
<ul>
|
||||
<li><code>SERVER_URL</code> - The URL where your Oculog API server is accessible (e.g., <code>http://192.168.1.100:3001</code> or <code>https://oculog.example.com</code>)</li>
|
||||
<li><code>SERVER_ID</code> - A unique identifier for this server (e.g., <code>web-server-01</code> or <code>db-prod-01</code>)</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>Example:</strong>
|
||||
</p>
|
||||
<pre className="wiki-code">
|
||||
{`curl -s http://192.168.1.100:3001/api/download-client/web-server-01?serverUrl=http://192.168.1.100:3001 | bash`}
|
||||
</pre>
|
||||
<p>
|
||||
<strong>What this command does:</strong>
|
||||
</p>
|
||||
<ol>
|
||||
<li>Downloads the pre-configured installer script from your Oculog server</li>
|
||||
<li>The installer includes your server URL and automatically generates an API key</li>
|
||||
<li>Installs the client as a systemd service</li>
|
||||
<li>Starts the client and begins sending metrics immediately</li>
|
||||
</ol>
|
||||
<p>
|
||||
<strong>Note:</strong> If you don't provide the <code>serverUrl</code> query parameter,
|
||||
the installer will use the server URL from the request. It's recommended to include it
|
||||
explicitly for clarity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="wiki-subsection">
|
||||
<h3>Manual Installation</h3>
|
||||
<p>
|
||||
If you prefer to download and install manually:
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
<strong>Download the installer script</strong>
|
||||
<pre className="wiki-code">
|
||||
{`# Using curl
|
||||
curl -o oculog-client-install.sh SERVER_URL/api/download-client/SERVER_ID?serverUrl=SERVER_URL
|
||||
|
||||
# Or using wget
|
||||
wget -O oculog-client-install.sh SERVER_URL/api/download-client/SERVER_ID?serverUrl=SERVER_URL`}
|
||||
</pre>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Make it executable</strong>
|
||||
<pre className="wiki-code">
|
||||
{`chmod +x oculog-client-install.sh`}
|
||||
</pre>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Run the installer</strong>
|
||||
<pre className="wiki-code">
|
||||
{`sudo ./oculog-client-install.sh`}
|
||||
</pre>
|
||||
</li>
|
||||
</ol>
|
||||
<p>
|
||||
The installer will:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Create the configuration directory at <code>/etc/oculog/</code></li>
|
||||
<li>Generate an API key and store it in <code>/etc/oculog/client.conf</code></li>
|
||||
<li>Install the client script to <code>/usr/local/bin/oculog-client</code></li>
|
||||
<li>Create and enable a systemd service</li>
|
||||
<li>Start the client service</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="wiki-subsection">
|
||||
<h3>Using the Download Client Dialog</h3>
|
||||
<p>
|
||||
You can also use the web dashboard to generate a custom installer:
|
||||
</p>
|
||||
<ol>
|
||||
<li>Click the download icon (plus icon) in the left sidebar</li>
|
||||
<li>Enter your Server ID (unique identifier for this server)</li>
|
||||
<li>Optionally enter a custom Server URL (leave empty to use the default)</li>
|
||||
<li>Click "Download Client Installer" to download the script</li>
|
||||
<li>Transfer the script to your Ubuntu server</li>
|
||||
<li>Run: <code>sudo bash oculog-client-install.sh</code></li>
|
||||
</ol>
|
||||
<p>
|
||||
The dialog also provides a ready-to-use curl command that you can copy and paste directly
|
||||
into your server terminal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="wiki-subsection">
|
||||
<h3>Verifying Installation</h3>
|
||||
<p>
|
||||
After installation, verify the client is running:
|
||||
</p>
|
||||
<pre className="wiki-code">
|
||||
{`# Check service status
|
||||
sudo systemctl status oculog-client
|
||||
|
||||
# View client logs
|
||||
sudo journalctl -u oculog-client -f
|
||||
|
||||
# Check configuration
|
||||
sudo cat /etc/oculog/client.conf`}
|
||||
</pre>
|
||||
<p>
|
||||
The client should appear in your Oculog dashboard within 30 seconds of installation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* How It Works Section */}
|
||||
<div className="wiki-section">
|
||||
<div
|
||||
className="wiki-section-header"
|
||||
onClick={() => toggleSection('architecture')}
|
||||
>
|
||||
<h2>How It Works</h2>
|
||||
<span className="wiki-expand-icon">
|
||||
{expandedSections.architecture ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
{expandedSections.architecture && (
|
||||
<div className="wiki-section-content">
|
||||
<div className="wiki-subsection">
|
||||
<h3>Architecture</h3>
|
||||
<p>
|
||||
Oculog uses a client-server architecture:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Server</strong>: Backend API (Express.js) + Frontend Dashboard (React) + PostgreSQL database</li>
|
||||
<li><strong>Clients</strong>: Lightweight Python agents installed on monitored servers</li>
|
||||
</ul>
|
||||
<p>
|
||||
Clients collect metrics and send them to the server via HTTP POST requests every 30 seconds
|
||||
(configurable). The server stores metrics in PostgreSQL and displays them in real-time
|
||||
on the web dashboard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="wiki-subsection">
|
||||
<h3>Metrics Collection</h3>
|
||||
<p>
|
||||
The client agent collects the following metrics:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>CPU Usage</strong>: Percentage utilization and core count</li>
|
||||
<li><strong>Memory</strong>: Total, used, available, and swap usage</li>
|
||||
<li><strong>Disk</strong>: Usage per mounted filesystem</li>
|
||||
<li><strong>Network</strong>: Bytes sent/received per interface</li>
|
||||
<li><strong>Process Count</strong>: Total number of running processes</li>
|
||||
<li><strong>Load Average</strong>: 1-minute, 5-minute, and 15-minute averages</li>
|
||||
<li><strong>Uptime</strong>: System uptime in seconds</li>
|
||||
</ul>
|
||||
<p>
|
||||
Metrics are collected every 30 seconds by default (configurable in <code>/etc/oculog/client.conf</code>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="wiki-subsection">
|
||||
<h3>Vulnerability Scanning</h3>
|
||||
<p>
|
||||
The client automatically scans installed packages for known vulnerabilities:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Scans run automatically every hour</li>
|
||||
<li>Uses <code>dpkg-query</code> to list all installed packages</li>
|
||||
<li>Queries the OSV (Open Source Vulnerabilities) API for vulnerability information</li>
|
||||
<li>Results are cached to reduce API calls</li>
|
||||
<li>Vulnerabilities are stored in the database and displayed in the Security view</li>
|
||||
</ul>
|
||||
<p>
|
||||
Vulnerabilities are categorized by severity (CRITICAL, HIGH, MEDIUM, LOW) and can be
|
||||
filtered by status (new, ongoing). When vulnerabilities are resolved (package updated),
|
||||
they are automatically removed from the database rather than being marked as "fixed".
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="wiki-subsection">
|
||||
<h3>Client Auto-Updates</h3>
|
||||
<p>
|
||||
The client automatically checks for updates:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Update checks run every hour</li>
|
||||
<li>Compares client version (build timestamp) with latest version from server</li>
|
||||
<li>If an update is available, the client automatically downloads and installs it</li>
|
||||
<li>The client restarts itself after a successful update</li>
|
||||
</ul>
|
||||
<p>
|
||||
Client versions are based on the modification time of the client.py source file,
|
||||
ensuring all clients from the same build have the same version identifier.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="wiki-subsection">
|
||||
<h3>Synthetic Monitors</h3>
|
||||
<p>
|
||||
Synthetic monitors allow you to monitor external endpoints:
|
||||
</p>
|
||||
<ul>
|
||||
<li>HTTP/HTTPS endpoint monitoring</li>
|
||||
<li>Configurable check intervals</li>
|
||||
<li>Tracks response time, status codes, and availability</li>
|
||||
<li>Results are stored and displayed in the Synthetic Monitors view</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="wiki-subsection">
|
||||
<h3>Alerting</h3>
|
||||
<p>
|
||||
Alert policies allow you to set thresholds for various metrics:
|
||||
</p>
|
||||
<ul>
|
||||
<li>CPU usage thresholds</li>
|
||||
<li>Memory usage thresholds</li>
|
||||
<li>Disk usage thresholds</li>
|
||||
<li>Network throughput thresholds</li>
|
||||
<li>Alert statuses: new, ongoing, resolved</li>
|
||||
</ul>
|
||||
<p>
|
||||
Alerts are evaluated continuously and displayed in the Alerting view.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tips & Tricks Section */}
|
||||
<div className="wiki-section">
|
||||
<div
|
||||
className="wiki-section-header"
|
||||
onClick={() => toggleSection('forceUpdate')}
|
||||
>
|
||||
<h2>Tips & Tricks</h2>
|
||||
<span className="wiki-expand-icon">
|
||||
{expandedSections.forceUpdate ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
{expandedSections.forceUpdate && (
|
||||
<div className="wiki-section-content">
|
||||
<div className="wiki-subsection">
|
||||
<h3>Force a Client Update</h3>
|
||||
<p>
|
||||
To force a client to update immediately (without waiting for the hourly check):
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
<strong>Method 1: Restart the client service</strong>
|
||||
<pre className="wiki-code">
|
||||
{`# On the monitored server
|
||||
sudo systemctl restart oculog-client`}
|
||||
</pre>
|
||||
<p>
|
||||
This will trigger an immediate update check when the client starts.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Method 2: Touch the client script on the server</strong>
|
||||
<pre className="wiki-code">
|
||||
{`# On the Oculog server (where client.py source is located)
|
||||
touch clients/ubuntu/client.py`}
|
||||
</pre>
|
||||
<p>
|
||||
This updates the file modification time, which changes the version identifier.
|
||||
Clients checking for updates will detect the new version and update automatically.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Method 3: Manually trigger update check</strong>
|
||||
<pre className="wiki-code">
|
||||
{`# On the monitored server, edit the client script temporarily
|
||||
# Change update_check_interval to a small value (e.g., 60 seconds)
|
||||
# Or manually call the check_for_updates method`}
|
||||
</pre>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="wiki-subsection">
|
||||
<h3>Force a Vulnerability Scan</h3>
|
||||
<p>
|
||||
To force a client to perform a vulnerability scan immediately:
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
<strong>Method 1: Restart the client service</strong>
|
||||
<pre className="wiki-code">
|
||||
{`# On the monitored server
|
||||
sudo systemctl restart oculog-client`}
|
||||
</pre>
|
||||
<p>
|
||||
The client will perform an initial scan when it starts, then continue with hourly scans.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Method 2: Temporarily reduce scan interval</strong>
|
||||
<pre className="wiki-code">
|
||||
{`# Edit the client script on the monitored server
|
||||
# Find VulnerabilityScanner class and change:
|
||||
# self.scan_interval = 60 # 1 minute instead of 3600 (1 hour)
|
||||
# Then restart: sudo systemctl restart oculog-client`}
|
||||
</pre>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Method 3: Check client logs</strong>
|
||||
<pre className="wiki-code">
|
||||
{`# View recent vulnerability scan activity
|
||||
sudo journalctl -u oculog-client -n 100 | grep -i vuln`}
|
||||
</pre>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="wiki-subsection">
|
||||
<h3>Clear All Vulnerability Data</h3>
|
||||
<p>
|
||||
To clear all vulnerability data and start fresh (useful after updating vulnerability extraction logic):
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
<strong>Method 1: Use the API endpoint (Recommended)</strong>
|
||||
<pre className="wiki-code">
|
||||
{`# Clear all vulnerabilities, server links, and cache
|
||||
curl -X POST http://your-server:3001/api/security/vulnerabilities/clear-all`}
|
||||
</pre>
|
||||
<p>
|
||||
This will:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Delete all vulnerability records</li>
|
||||
<li>Delete all server-vulnerability links (automatically via CASCADE)</li>
|
||||
<li>Clear the vulnerability cache</li>
|
||||
<li>Return a summary of what was deleted</li>
|
||||
</ul>
|
||||
<p>
|
||||
The API endpoint runs in a transaction, so if anything fails, all changes are rolled back.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Method 2: Direct SQL commands</strong>
|
||||
<pre className="wiki-code">
|
||||
{`# Connect to PostgreSQL
|
||||
psql -U your_user -d your_database
|
||||
|
||||
# Clear vulnerability cache
|
||||
DELETE FROM vulnerability_cache;
|
||||
|
||||
# Clear vulnerabilities (CASCADE will automatically delete server_vulnerabilities)
|
||||
DELETE FROM vulnerabilities;
|
||||
|
||||
# Verify server_vulnerabilities is empty (should be automatic due to CASCADE)
|
||||
SELECT COUNT(*) FROM server_vulnerabilities;`}
|
||||
</pre>
|
||||
<p>
|
||||
<strong>Note:</strong> The <code>server_vulnerabilities</code> table has a foreign key with <code>ON DELETE CASCADE</code>,
|
||||
so deleting vulnerabilities will automatically delete all related server links.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
<p>
|
||||
<strong>After clearing:</strong> The next vulnerability scan from your clients will repopulate the data
|
||||
with the updated extraction logic (proper CVE IDs, severity, fixed versions, etc.).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="wiki-subsection">
|
||||
<h3>Cleanup Duplicate Vulnerabilities</h3>
|
||||
<p>
|
||||
If you notice duplicate vulnerability records, you can clean them up:
|
||||
</p>
|
||||
<pre className="wiki-code">
|
||||
{`# Remove duplicate vulnerabilities (keeps the most complete record)
|
||||
curl -X POST http://your-server:3001/api/security/vulnerabilities/cleanup`}
|
||||
</pre>
|
||||
<p>
|
||||
This will:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Find duplicates based on CVE ID + package name + ecosystem</li>
|
||||
<li>Keep the record with the most complete data (has severity, fixed_version, etc.)</li>
|
||||
<li>Remove duplicate records</li>
|
||||
<li>Return the count of duplicates removed</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="wiki-subsection">
|
||||
<h3>View Client Logs</h3>
|
||||
<p>
|
||||
Monitor client activity and troubleshoot issues:
|
||||
</p>
|
||||
<pre className="wiki-code">
|
||||
{`# View all logs
|
||||
sudo journalctl -u oculog-client -f
|
||||
|
||||
# View last 100 lines
|
||||
sudo journalctl -u oculog-client -n 100
|
||||
|
||||
# View logs from today
|
||||
sudo journalctl -u oculog-client --since today
|
||||
|
||||
# Filter for specific events
|
||||
sudo journalctl -u oculog-client | grep -i "update"
|
||||
sudo journalctl -u oculog-client | grep -i "vulnerability"
|
||||
sudo journalctl -u oculog-client | grep -i "error"`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="wiki-subsection">
|
||||
<h3>Check Client Status</h3>
|
||||
<p>
|
||||
Verify the client is running correctly:
|
||||
</p>
|
||||
<pre className="wiki-code">
|
||||
{`# Check service status
|
||||
sudo systemctl status oculog-client
|
||||
|
||||
# Check if client process is running
|
||||
ps aux | grep oculog-client
|
||||
|
||||
# Verify configuration
|
||||
sudo cat /etc/oculog/client.conf
|
||||
|
||||
# Test API connectivity
|
||||
curl -H "X-API-Key: YOUR_API_KEY" http://YOUR_SERVER:3001/health`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="wiki-subsection">
|
||||
<h3>View Metrics via API</h3>
|
||||
<p>
|
||||
Access metrics programmatically:
|
||||
</p>
|
||||
<pre className="wiki-code">
|
||||
{`# List all servers
|
||||
curl http://localhost:3001/api/servers
|
||||
|
||||
# Get latest metrics for a server
|
||||
curl http://localhost:3001/api/servers/SERVER_ID/metrics?limit=10
|
||||
|
||||
# Get server info
|
||||
curl http://localhost:3001/api/servers/SERVER_ID/info
|
||||
|
||||
# Get vulnerability stats
|
||||
curl http://localhost:3001/api/servers/SERVER_ID/vulnerabilities/stats`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="wiki-subsection">
|
||||
<h3>Client Configuration</h3>
|
||||
<p>
|
||||
The client configuration file is located at <code>/etc/oculog/client.conf</code>:
|
||||
</p>
|
||||
<pre className="wiki-code">
|
||||
{`{
|
||||
"server_url": "http://your-server:3001",
|
||||
"server_id": "unique-server-identifier",
|
||||
"api_key": "your-api-key",
|
||||
"interval": 30
|
||||
}`}
|
||||
</pre>
|
||||
<p>
|
||||
After modifying the configuration, restart the client:
|
||||
</p>
|
||||
<pre className="wiki-code">
|
||||
{`sudo systemctl restart oculog-client`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="wiki-subsection">
|
||||
<h3>API Key Management</h3>
|
||||
<p>
|
||||
API keys are used to authenticate client requests:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Each server should have its own API key</li>
|
||||
<li>Keys are created automatically during client installation</li>
|
||||
<li>Keys can be managed via the API: <code>POST /api/keys</code> and <code>GET /api/keys/:serverId</code></li>
|
||||
<li>Keys are stored hashed in the database for security</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="wiki-subsection">
|
||||
<h3>Troubleshooting</h3>
|
||||
<p>
|
||||
Common issues and solutions:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Client not sending metrics</strong>: Check network connectivity, API key, and service status
|
||||
</li>
|
||||
<li>
|
||||
<strong>Outdated client version</strong>: Restart the service to trigger an update check
|
||||
</li>
|
||||
<li>
|
||||
<strong>No vulnerabilities showing</strong>: Wait for the hourly scan or restart the client
|
||||
</li>
|
||||
<li>
|
||||
<strong>High API usage</strong>: Vulnerability results are cached to minimize API calls
|
||||
</li>
|
||||
<li>
|
||||
<strong>Database connection issues</strong>: Check PostgreSQL is running and accessible
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* API Endpoints Section */}
|
||||
<div className="wiki-section">
|
||||
<div
|
||||
className="wiki-section-header"
|
||||
onClick={() => toggleSection('endpoints')}
|
||||
>
|
||||
<h2>API Endpoints</h2>
|
||||
<span className="wiki-expand-icon">
|
||||
{expandedSections.endpoints ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
{expandedSections.endpoints && (
|
||||
<div className="wiki-section-content">
|
||||
<p>
|
||||
Key API endpoints for reference:
|
||||
</p>
|
||||
<ul>
|
||||
<li><code>GET /health</code> - Health check</li>
|
||||
<li><code>GET /api/servers</code> - List all servers</li>
|
||||
<li><code>GET /api/servers/:serverId/metrics</code> - Get metrics for a server</li>
|
||||
<li><code>POST /api/servers/:serverId/metrics</code> - Submit metrics (client use)</li>
|
||||
<li><code>GET /api/servers/:serverId/info</code> - Get server information</li>
|
||||
<li><code>GET /api/client-version/latest</code> - Get latest client version</li>
|
||||
<li><code>GET /api/client-script</code> - Download client script</li>
|
||||
<li><code>POST /api/servers/:serverId/vulnerabilities/scan</code> - Submit vulnerability scan (client use)</li>
|
||||
<li><code>GET /api/servers/:serverId/vulnerabilities</code> - Get vulnerabilities for a server</li>
|
||||
<li><code>GET /api/servers/:serverId/vulnerabilities/stats</code> - Get vulnerability statistics for a server</li>
|
||||
<li><code>POST /api/security/vulnerabilities/clear-all</code> - Clear all vulnerability data</li>
|
||||
<li><code>POST /api/security/vulnerabilities/cleanup</code> - Remove duplicate vulnerabilities</li>
|
||||
<li><code>GET /api/synthetic-monitors</code> - List synthetic monitors</li>
|
||||
<li><code>GET /api/alert-policies</code> - List alert policies</li>
|
||||
<li><code>GET /api/alerts</code> - List alerts</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Wiki;
|
||||
62
server/frontend/src/index.css
Normal file
62
server/frontend/src/index.css
Normal file
@@ -0,0 +1,62 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
overscroll-behavior-y: none;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-moz-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #0f172a;
|
||||
color: #e2e8f0;
|
||||
overscroll-behavior-y: none;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-moz-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Improve touch interactions on mobile */
|
||||
button, a, select, input {
|
||||
-webkit-tap-highlight-color: rgba(252, 41, 34, 0.2);
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* Prevent horizontal scroll on mobile */
|
||||
@media (max-width: 768px) {
|
||||
html, body {
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide React error overlay for external script errors */
|
||||
iframe[src*="react-error-overlay"],
|
||||
div[data-react-error-overlay],
|
||||
div[style*="position: fixed"][style*="z-index: 2147483647"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* More specific selector for React error overlay */
|
||||
body > div:last-child[style*="position: fixed"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
216
server/frontend/src/index.js
Normal file
216
server/frontend/src/index.js
Normal file
@@ -0,0 +1,216 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
|
||||
// Suppress external script errors (browser extensions, injected scripts)
|
||||
window.addEventListener('error', (event) => {
|
||||
const message = event.message || '';
|
||||
const filename = event.filename || '';
|
||||
|
||||
// Suppress errors from external sources (browser extensions, injected scripts)
|
||||
if (
|
||||
message === 'Script error.' ||
|
||||
message.includes('ethereum') ||
|
||||
message.includes('web3') ||
|
||||
message.includes('selectedAddress') ||
|
||||
message.includes('window.ethereum') ||
|
||||
message.includes('undefined is not an object') ||
|
||||
filename === '' ||
|
||||
filename.includes('chrome-extension://') ||
|
||||
filename.includes('moz-extension://') ||
|
||||
filename.includes('safari-extension://')
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Suppress unhandled promise rejections from external sources
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const message = event.reason?.message || event.reason?.toString() || '';
|
||||
if (
|
||||
message.includes('ethereum') ||
|
||||
message.includes('web3') ||
|
||||
message === 'Script error.'
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Suppress console errors for known external errors
|
||||
const originalConsoleError = console.error;
|
||||
console.error = (...args) => {
|
||||
const message = args.join(' ');
|
||||
if (
|
||||
message.includes('ethereum') ||
|
||||
message.includes('web3') ||
|
||||
message === 'Script error.' ||
|
||||
message.includes('window.ethereum') ||
|
||||
message.includes('selectedAddress') ||
|
||||
message.includes('undefined is not an object') ||
|
||||
message.includes('handleError@')
|
||||
) {
|
||||
return; // Suppress these errors
|
||||
}
|
||||
originalConsoleError.apply(console, args);
|
||||
};
|
||||
|
||||
// Intercept and suppress React error overlay for external script errors
|
||||
// This runs after React loads to catch errors that React's overlay might show
|
||||
const suppressReactErrorOverlay = () => {
|
||||
// Check for React error overlay and dismiss it if it shows external errors
|
||||
const checkAndDismissOverlay = () => {
|
||||
// Look for the error overlay container - check multiple possible selectors
|
||||
const selectors = [
|
||||
'[data-react-error-overlay]',
|
||||
'iframe[src*="react-error-overlay"]',
|
||||
'div[style*="position: fixed"][style*="z-index: 2147483647"]',
|
||||
'div[style*="position:fixed"][style*="z-index:2147483647"]',
|
||||
'body > div:last-child[style*="position: fixed"]',
|
||||
'body > iframe:last-child'
|
||||
];
|
||||
|
||||
let overlay = null;
|
||||
for (const selector of selectors) {
|
||||
overlay = document.querySelector(selector);
|
||||
if (overlay) break;
|
||||
}
|
||||
|
||||
if (overlay) {
|
||||
// Check if the overlay contains external error messages
|
||||
let overlayText = '';
|
||||
try {
|
||||
overlayText = overlay.textContent || overlay.innerText || '';
|
||||
// Also check iframe content if it's an iframe
|
||||
if (overlay.tagName === 'IFRAME' && overlay.contentDocument) {
|
||||
overlayText += overlay.contentDocument.body?.textContent || '';
|
||||
}
|
||||
} catch (e) {
|
||||
// Can't access iframe content (cross-origin), but that's okay
|
||||
}
|
||||
|
||||
// Always hide if it contains external errors, or hide all overlays with high z-index
|
||||
const hasExternalError = overlayText.includes('Script error') ||
|
||||
overlayText.includes('ethereum') ||
|
||||
overlayText.includes('selectedAddress') ||
|
||||
overlayText.includes('window.ethereum') ||
|
||||
overlayText.includes('handleError@');
|
||||
|
||||
if (hasExternalError || overlayText === '') {
|
||||
// Try to find and click the dismiss button first
|
||||
const dismissButton = overlay.querySelector('button[aria-label*="close"]') ||
|
||||
overlay.querySelector('button[aria-label*="dismiss"]') ||
|
||||
overlay.querySelector('button:last-child') ||
|
||||
overlay.querySelector('[role="button"]');
|
||||
|
||||
if (dismissButton) {
|
||||
try {
|
||||
dismissButton.click();
|
||||
} catch (e) {
|
||||
// If click fails, remove the overlay
|
||||
overlay.remove();
|
||||
}
|
||||
} else {
|
||||
// If no button found, hide or remove the overlay
|
||||
overlay.style.display = 'none';
|
||||
overlay.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check all fixed position divs with high z-index or that contain error text
|
||||
const allFixedDivs = document.querySelectorAll('div[style*="position: fixed"], div[style*="position:fixed"]');
|
||||
allFixedDivs.forEach(div => {
|
||||
const style = window.getComputedStyle(div);
|
||||
const zIndex = parseInt(style.zIndex) || 0;
|
||||
const text = div.textContent || div.innerText || '';
|
||||
|
||||
// Hide if high z-index and contains error-related text
|
||||
if (zIndex > 10000 ||
|
||||
text.includes('Uncaught runtime errors') ||
|
||||
text.includes('Script error') ||
|
||||
text.includes('ethereum') ||
|
||||
text.includes('handleError@')) {
|
||||
// Check if it's specifically an external error before hiding
|
||||
if (text.includes('Script error') ||
|
||||
text.includes('ethereum') ||
|
||||
text.includes('selectedAddress') ||
|
||||
text.includes('window.ethereum') ||
|
||||
(text.includes('Uncaught runtime errors') && text.includes('Script error'))) {
|
||||
div.style.display = 'none';
|
||||
div.style.visibility = 'hidden';
|
||||
div.style.opacity = '0';
|
||||
div.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Also check for any div containing "Uncaught runtime errors" text
|
||||
const errorOverlays = document.querySelectorAll('div');
|
||||
errorOverlays.forEach(div => {
|
||||
const text = div.textContent || div.innerText || '';
|
||||
if (text.includes('Uncaught runtime errors') &&
|
||||
(text.includes('Script error') || text.includes('ethereum'))) {
|
||||
const style = window.getComputedStyle(div);
|
||||
if (style.position === 'fixed' || style.position === 'absolute') {
|
||||
div.style.display = 'none';
|
||||
div.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Check immediately and then more frequently
|
||||
checkAndDismissOverlay();
|
||||
// Check every 100ms to catch overlay quickly
|
||||
setInterval(checkAndDismissOverlay, 100);
|
||||
|
||||
// Also listen for DOM changes to catch overlay when it appears
|
||||
const observer = new MutationObserver(() => {
|
||||
checkAndDismissOverlay();
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
};
|
||||
|
||||
// Suppress React's error reporting mechanism
|
||||
if (typeof window !== 'undefined') {
|
||||
// Override React's error handler if it exists
|
||||
const originalHandleError = window.__REACT_ERROR_OVERLAY_GLOBAL_HANDLER__;
|
||||
if (originalHandleError) {
|
||||
window.__REACT_ERROR_OVERLAY_GLOBAL_HANDLER__ = function(error, isFatal) {
|
||||
const errorMessage = error?.message || error?.toString() || '';
|
||||
if (
|
||||
errorMessage.includes('Script error') ||
|
||||
errorMessage.includes('ethereum') ||
|
||||
errorMessage.includes('selectedAddress') ||
|
||||
errorMessage.includes('window.ethereum')
|
||||
) {
|
||||
return; // Suppress the error
|
||||
}
|
||||
// Call original handler for other errors
|
||||
if (originalHandleError) {
|
||||
originalHandleError(error, isFatal);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// Start suppressing React error overlay after React renders
|
||||
setTimeout(suppressReactErrorOverlay, 1000);
|
||||
|
||||
Reference in New Issue
Block a user