Add full Oculog codebase

This commit is contained in:
shump
2026-02-12 14:52:37 -06:00
parent 49d8d01643
commit 5c6a17abf3
68 changed files with 34218 additions and 0 deletions

40
.gitignore vendored Normal file
View 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
View 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"

View 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."

View 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
View 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()

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

View 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

View File

@@ -0,0 +1,3 @@
psutil==5.9.6
requests==2.31.0

View 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
View 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
View 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
View 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:

View File

@@ -0,0 +1,7 @@
node_modules
npm-debug.log
.env
.git
.gitignore
README.md

19
server/backend/Dockerfile Normal file
View 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
View 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);

View 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);

View 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 $$;

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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

File diff suppressed because it is too large Load Diff

1067
server/backend/src/index.js Normal file

File diff suppressed because it is too large Load Diff

View 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
};

View 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();

View 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 };

View 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
};

View 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
};

View 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
};

View File

@@ -0,0 +1,8 @@
node_modules
npm-debug.log
.env
.git
.gitignore
README.md
build

View 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

File diff suppressed because it is too large Load Diff

View 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"
]
}
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

298
server/frontend/src/App.css Normal file
View 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
View 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;

View 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;
}
}

View 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;

View 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;
}

View 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;

View 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;

View 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;
}

View 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;

View 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;
}
}

View 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;

View 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);
}

View 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;

View 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;
}
}

View 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;

View 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;
}
}

View 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;

View 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);
}

View 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;

View 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;
}

View 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;

View 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);
}
}

View 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;

View 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;
}
}

View 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;

View 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;
}
}

View 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;

View 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;
}

View 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);