#!/usr/bin/env python3 """ SamCloud Enterprise Home Lab Dashboard A sophisticated, dynamic monitoring solution inspired by macOS design principles and modern observability practices. Built for macOS 15.3.2 Sequoia with full Docker/OrbStack integration, Homebrew services monitoring, and real-time metrics. Features: - Real-time container health monitoring with restart capabilities - Homebrew services integration and management - Native macOS service monitoring - Live resource tracking and historical data - Service dependency mapping - Interactive service management - Apple HIG 2025 compliant design - WebSocket-based real-time updates """ import asyncio import json import os import platform import psutil import requests import socket import subprocess import time import logging from datetime import datetime, timedelta from pathlib import Path from typing import Dict, List, Optional, Any, Tuple from collections import defaultdict, deque from dataclasses import dataclass, asdict from flask import Flask, render_template_string, jsonify, request from flask_socketio import SocketIO, emit from flask_cors import CORS import threading # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) app = Flask(__name__) app.config['SECRET_KEY'] = 'samcloud-homelab-secret' CORS(app) socketio = SocketIO(app, cors_allowed_origins="*") @dataclass class ServiceMetrics: """Service performance metrics""" cpu_percent: float = 0.0 memory_mb: float = 0.0 memory_percent: float = 0.0 network_rx_mb: float = 0.0 network_tx_mb: float = 0.0 disk_read_mb: float = 0.0 disk_write_mb: float = 0.0 restart_count: int = 0 uptime_seconds: int = 0 timestamp: str = "" @dataclass class ServiceStatus: """Comprehensive service status""" name: str display_name: str category: str icon: str color: str status: str # running, stopped, restarting, error, healthy health_score: int # 0-100 url: Optional[str] description: str container_id: Optional[str] image: Optional[str] ports: List[Dict] environment: str # docker, homebrew, native, kubernetes metrics: ServiceMetrics logs: List[str] # Recent log entries dependencies: List[str] # Service dependencies last_updated: str class AdvancedHomeLab: def __init__(self): self.ddns_monitor_path = "/Volumes/SamMgmt/CF DDClient/ddns_monitor.py" self.start_time = datetime.now() # Metrics history (last 100 data points) self.metrics_history = defaultdict(lambda: deque(maxlen=100)) # Service definitions with enhanced metadata self.service_definitions = { # Media Automation Stack 'radarr': { 'display_name': 'Radarr', 'category': '🎬 Media Automation', 'icon': '🎬', 'color': '#ffc107', 'description': 'Movie collection manager with automatic downloading', 'dependencies': ['prowlarr', 'qbittorrent'], 'health_endpoints': ['/api/v3/system/status'] }, 'sonarr': { 'display_name': 'Sonarr', 'category': '🎬 Media Automation', 'icon': 'πŸ“Ί', 'color': '#2196f3', 'description': 'TV series collection manager with episode tracking', 'dependencies': ['prowlarr', 'qbittorrent'], 'health_endpoints': ['/api/v3/system/status'] }, 'lidarr': { 'display_name': 'Lidarr', 'category': '🎬 Media Automation', 'icon': '🎡', 'color': '#9c27b0', 'description': 'Music collection manager for artists and albums', 'dependencies': ['prowlarr', 'qbittorrent'], 'health_endpoints': ['/api/v1/system/status'] }, 'bazarr': { 'display_name': 'Bazarr', 'category': '🎬 Media Automation', 'icon': 'πŸ’¬', 'color': '#607d8b', 'description': 'Subtitle management for Radarr and Sonarr', 'dependencies': ['radarr', 'sonarr'], 'health_endpoints': ['/api/system/status'] }, 'prowlarr': { 'display_name': 'Prowlarr', 'category': '🎬 Media Automation', 'icon': 'πŸ”', 'color': '#795548', 'description': 'Indexer manager and proxy for *arr applications', 'dependencies': [], 'health_endpoints': ['/api/v1/system/status'] }, 'jackett': { 'display_name': 'Jackett', 'category': '🎬 Media Automation', 'icon': 'πŸ§₯', 'color': '#ff9800', 'description': 'API Support for torrent trackers', 'dependencies': [], 'health_endpoints': ['/api/v2.0/server/config'] }, 'jellyseerr': { 'display_name': 'Jellyseerr', 'category': '🎬 Media Requests', 'icon': '🎭', 'color': '#ff5722', 'description': 'Media request management for Plex and Jellyfin', 'dependencies': ['radarr', 'sonarr'], 'health_endpoints': ['/api/v1/status'] }, # Download Stack 'qbittorrent': { 'display_name': 'qBittorrent', 'category': 'πŸ“₯ Downloads', 'icon': 'πŸ’Ύ', 'color': '#4caf50', 'description': 'BitTorrent client with web interface', 'dependencies': [], 'health_endpoints': ['/api/v2/app/version'] }, # Media Servers 'navidrome': { 'display_name': 'Navidrome', 'category': '🎡 Media Server', 'icon': '🎼', 'color': '#e91e63', 'description': 'Modern music server compatible with Subsonic/Airsonic', 'dependencies': [], 'health_endpoints': ['/ping'] }, 'immich_server': { 'display_name': 'Immich Photos', 'category': 'πŸ“· Media Server', 'icon': 'πŸ“Έ', 'color': '#2196f3', 'description': 'High-performance self-hosted photo and video management', 'dependencies': ['immich_postgres', 'immich_redis', 'immich_machine_learning'], 'health_endpoints': ['/api/server-info/ping'] }, 'spotspot': { 'display_name': 'SpotSpot', 'category': '🎡 Media Server', 'icon': '🎧', 'color': '#1db954', 'description': 'Spotify playlist and music management', 'dependencies': [], 'health_endpoints': ['/health'] }, # Infrastructure 'npm': { 'display_name': 'Nginx Proxy Manager', 'category': 'πŸ”§ Infrastructure', 'icon': '🌐', 'color': '#4caf50', 'description': 'Reverse proxy manager with Let\'s Encrypt integration', 'dependencies': [], 'health_endpoints': ['/api'] }, 'immich_postgres': { 'display_name': 'Immich Database', 'category': 'πŸ”§ Infrastructure', 'icon': 'πŸ—„οΈ', 'color': '#336791', 'description': 'PostgreSQL database for Immich photo management', 'dependencies': [], 'health_endpoints': [] }, 'immich_redis': { 'display_name': 'Immich Cache', 'category': 'πŸ”§ Infrastructure', 'icon': '⚑', 'color': '#dc382d', 'description': 'Redis cache for Immich performance optimization', 'dependencies': [], 'health_endpoints': [] }, 'immich_machine_learning': { 'display_name': 'Immich ML', 'category': 'πŸ€– AI/ML', 'icon': '🧠', 'color': '#9c27b0', 'description': 'Machine learning services for photo recognition', 'dependencies': [], 'health_endpoints': ['/ping'] }, 'filebrowser': { 'display_name': 'File Browser', 'category': 'πŸ—‚οΈ File Management', 'icon': 'πŸ“', 'color': '#607d8b', 'description': 'Web-based file manager with sharing capabilities', 'dependencies': [], 'health_endpoints': ['/health'] }, # Dashboards & Monitoring 'homepage': { 'display_name': 'Homepage', 'category': 'πŸ“Š Dashboard', 'icon': '🏑', 'color': '#4caf50', 'description': 'Customizable home lab dashboard and service launcher', 'dependencies': [], 'health_endpoints': ['/api/config'] }, 'dashy': { 'display_name': 'Dashy', 'category': 'πŸ“Š Dashboard', 'icon': '🏠', 'color': '#667eea', 'description': 'Feature-rich home lab dashboard with widgets', 'dependencies': [], 'health_endpoints': ['/ping'] }, 'uptime-kuma': { 'display_name': 'Uptime Kuma', 'category': 'πŸ“Š Monitoring', 'icon': 'πŸ“ˆ', 'color': '#ff9800', 'description': 'Self-hosted uptime monitoring with notifications', 'dependencies': [], 'health_endpoints': ['/api'] }, # AI/ML 'openwebui': { 'display_name': 'Open WebUI', 'category': 'πŸ€– AI/ML', 'icon': '🧠', 'color': '#9c27b0', 'description': 'Web interface for Ollama and OpenAI compatible APIs', 'dependencies': ['ollama'], 'health_endpoints': ['/health'] }, # Security & Authentication 'authentik_server': { 'display_name': 'Authentik SSO', 'category': 'πŸ”’ Security', 'icon': 'πŸ›‘οΈ', 'color': '#f44336', 'description': 'Identity provider and SSO solution', 'dependencies': ['authentik_postgres', 'authentik_redis'], 'health_endpoints': ['/api/v3/root/config/'] }, # Cloud & Storage 'nextcloud-aio-mastercontainer': { 'display_name': 'Nextcloud AIO', 'category': '☁️ Cloud Storage', 'icon': '☁️', 'color': '#0082c9', 'description': 'All-in-one Nextcloud deployment', 'dependencies': [], 'health_endpoints': ['/api/docker/container'] }, # Network & DNS 'pihole': { 'display_name': 'Pi-hole', 'category': '🌐 Network', 'icon': 'πŸ•³οΈ', 'color': '#96060c', 'description': 'Network-wide ad blocking and DNS sinkhole', 'dependencies': [], 'health_endpoints': ['/admin/api.php'] }, 'wgdashboard': { 'display_name': 'WireGuard Dashboard', 'category': '🌐 Network', 'icon': 'πŸ”', 'color': '#88171a', 'description': 'WireGuard VPN management interface', 'dependencies': [], 'health_endpoints': ['/'] }, # Development 'code-server': { 'display_name': 'Code Server', 'category': 'πŸ’» Development', 'icon': 'πŸ‘¨β€πŸ’»', 'color': '#007acc', 'description': 'VS Code in the browser for remote development', 'dependencies': [], 'health_endpoints': ['/healthz'] }, # Homebrew Services 'ddclient': { 'display_name': 'DDClient', 'category': '🌐 Network', 'icon': '🌍', 'color': '#4caf50', 'description': 'Dynamic DNS client for domain updates', 'dependencies': [], 'health_endpoints': [] }, 'nginx': { 'display_name': 'Nginx', 'category': 'πŸ”§ Infrastructure', 'icon': '🌐', 'color': '#009639', 'description': 'High-performance web server and reverse proxy', 'dependencies': [], 'health_endpoints': ['/nginx_status'] }, 'dnsmasq': { 'display_name': 'DNSmasq', 'category': '🌐 Network', 'icon': 'πŸ—‚οΈ', 'color': '#2196f3', 'description': 'Lightweight DNS forwarder and DHCP server', 'dependencies': [], 'health_endpoints': [] }, 'ollama': { 'display_name': 'Ollama', 'category': 'πŸ€– AI/ML', 'icon': 'πŸ¦™', 'color': '#ff6b35', 'description': 'Local LLM server for running AI models', 'dependencies': [], 'health_endpoints': ['/api/version'] }, 'postgresql': { 'display_name': 'PostgreSQL', 'category': 'πŸ”§ Infrastructure', 'icon': '🐘', 'color': '#336791', 'description': 'Advanced open-source relational database', 'dependencies': [], 'health_endpoints': [] }, 'caddy': { 'display_name': 'Caddy', 'category': 'πŸ”§ Infrastructure', 'icon': 'πŸ•ΈοΈ', 'color': '#1f88e5', 'description': 'Modern web server with automatic HTTPS', 'dependencies': [], 'health_endpoints': ['/health', '/'] } } # Background thread for metrics collection self.metrics_thread = None self.metrics_running = False def start_metrics_collection(self): """Start background metrics collection""" if not self.metrics_running: self.metrics_running = True self.metrics_thread = threading.Thread(target=self._collect_metrics_loop, daemon=True) self.metrics_thread.start() logger.info("Started metrics collection thread") def _collect_metrics_loop(self): """Background loop for collecting metrics""" while self.metrics_running: try: # Collect system metrics system_metrics = self.get_system_metrics() self.metrics_history['system'].append({ 'timestamp': datetime.now().isoformat(), 'data': system_metrics }) # Collect service metrics services = self.get_all_services() for service in services: self.metrics_history[f"service_{service.name}"].append({ 'timestamp': datetime.now().isoformat(), 'data': asdict(service.metrics) }) # Emit real-time updates via WebSocket socketio.emit('metrics_update', { 'system': system_metrics, 'services': [asdict(s) for s in services], 'timestamp': datetime.now().isoformat() }) time.sleep(5) # Collect every 5 seconds except Exception as e: logger.error(f"Error in metrics collection: {e}") time.sleep(10) # Wait longer on error def get_system_metrics(self) -> Dict[str, Any]: """Get comprehensive system metrics""" try: # CPU information cpu_percent = psutil.cpu_percent(interval=1) cpu_count = psutil.cpu_count() cpu_freq = psutil.cpu_freq() load_avg = os.getloadavg() if hasattr(os, 'getloadavg') else [0, 0, 0] # Memory information memory = psutil.virtual_memory() # Disk information disk = psutil.disk_usage('/') # Network stats net_io = psutil.net_io_counters() # Boot time and uptime boot_time = datetime.fromtimestamp(psutil.boot_time()) uptime = datetime.now() - boot_time # Temperature (if available on macOS) temperatures = {} try: temps = psutil.sensors_temperatures() if temps: temperatures = {name: [t._asdict() for t in temp_list] for name, temp_list in temps.items()} except Exception: pass return { 'hostname': socket.gethostname(), 'platform': f"{platform.system()} {platform.release()}", 'architecture': platform.machine(), 'uptime_seconds': int(uptime.total_seconds()), 'uptime_display': str(uptime).split('.')[0], 'boot_time': boot_time.isoformat(), 'cpu': { 'usage_percent': round(cpu_percent, 1), 'count': cpu_count, 'frequency_mhz': round(cpu_freq.current) if cpu_freq else 0, 'load_avg': [round(x, 2) for x in load_avg] }, 'memory': { 'total_gb': round(memory.total / (1024**3), 2), 'used_gb': round(memory.used / (1024**3), 2), 'available_gb': round(memory.available / (1024**3), 2), 'usage_percent': round(memory.percent, 1), 'cached_gb': round(memory.cached / (1024**3), 2) if hasattr(memory, 'cached') else 0 }, 'disk': { 'total_gb': round(disk.total / (1024**3), 2), 'used_gb': round(disk.used / (1024**3), 2), 'free_gb': round(disk.free / (1024**3), 2), 'usage_percent': round((disk.used / disk.total) * 100, 1) }, 'network': { 'bytes_sent_gb': round(net_io.bytes_sent / (1024**3), 2), 'bytes_recv_gb': round(net_io.bytes_recv / (1024**3), 2), 'packets_sent': net_io.packets_sent, 'packets_recv': net_io.packets_recv }, 'temperatures': temperatures } except Exception as e: logger.error(f"Error getting system metrics: {e}") return {'error': str(e)} def get_docker_services(self) -> List[ServiceStatus]: """Get Docker container services with detailed metrics""" services = [] try: # Get container list result = subprocess.run( ['docker', 'ps', '-a', '--format', 'json'], capture_output=True, text=True, timeout=15 ) if result.returncode == 0: for line in result.stdout.strip().split('\n'): if line.strip(): container_data = json.loads(line) service = self._create_docker_service_status(container_data) if service and not service.name.startswith('k8s_POD'): # Filter out k8s pause containers services.append(service) except Exception as e: logger.error(f"Error getting Docker services: {e}") return services def _create_docker_service_status(self, container_data: Dict) -> Optional[ServiceStatus]: """Create ServiceStatus from Docker container data""" try: name = container_data.get('Names', '') image = container_data.get('Image', '') status = container_data.get('Status', '') container_id = container_data.get('ID', '') # Skip Kubernetes system containers if name.startswith('k8s_') and ('pause' in image or 'coredns' in name): return None # Get service definition service_key = name.lower().replace('_', '-').split('_')[0] if '_' in name else name.lower() service_def = self.service_definitions.get(service_key, {}) # Parse ports ports_raw = container_data.get('Ports', '') ports = self._parse_docker_ports(ports_raw) # Determine status and health is_running = 'Up' in status is_healthy = 'healthy' in status.lower() is_restarting = 'Restarting' in status if is_restarting: service_status = 'restarting' health_score = 25 elif is_running and is_healthy: service_status = 'healthy' health_score = 100 elif is_running: service_status = 'running' health_score = 75 else: service_status = 'stopped' health_score = 0 # Get container metrics metrics = self._get_container_metrics(container_id) # Get recent logs logs = self._get_container_logs(container_id) # Get URL url = self._get_service_url(ports) return ServiceStatus( name=name, display_name=service_def.get('display_name', name.replace('_', ' ').title()), category=service_def.get('category', '🐳 Container'), icon=service_def.get('icon', 'βš™οΈ'), color=service_def.get('color', '#607d8b'), status=service_status, health_score=health_score, url=url, description=service_def.get('description', 'Docker container service'), container_id=container_id, image=image, ports=ports, environment='docker', metrics=metrics, logs=logs, dependencies=service_def.get('dependencies', []), last_updated=datetime.now().isoformat() ) except Exception as e: logger.error(f"Error creating Docker service status: {e}") return None def _get_container_metrics(self, container_id: str) -> ServiceMetrics: """Get detailed metrics for a Docker container""" try: if not container_id: return ServiceMetrics() # Get container stats result = subprocess.run( ['docker', 'stats', container_id, '--no-stream', '--format', 'json'], capture_output=True, text=True, timeout=10 ) if result.returncode == 0: stats = json.loads(result.stdout.strip()) # Parse CPU percentage cpu_str = stats.get('CPUPerc', '0%').replace('%', '') cpu_percent = float(cpu_str) if cpu_str != 'N/A' else 0.0 # Parse memory mem_usage_str = stats.get('MemUsage', '0B / 0B') mem_parts = mem_usage_str.split(' / ') memory_mb = self._parse_size_to_mb(mem_parts[0]) if len(mem_parts) > 0 else 0.0 mem_perc_str = stats.get('MemPerc', '0%').replace('%', '') memory_percent = float(mem_perc_str) if mem_perc_str != 'N/A' else 0.0 # Parse network I/O net_io_str = stats.get('NetIO', '0B / 0B') net_parts = net_io_str.split(' / ') network_rx_mb = self._parse_size_to_mb(net_parts[0]) if len(net_parts) > 0 else 0.0 network_tx_mb = self._parse_size_to_mb(net_parts[1]) if len(net_parts) > 1 else 0.0 # Parse block I/O block_io_str = stats.get('BlockIO', '0B / 0B') block_parts = block_io_str.split(' / ') disk_read_mb = self._parse_size_to_mb(block_parts[0]) if len(block_parts) > 0 else 0.0 disk_write_mb = self._parse_size_to_mb(block_parts[1]) if len(block_parts) > 1 else 0.0 return ServiceMetrics( cpu_percent=cpu_percent, memory_mb=memory_mb, memory_percent=memory_percent, network_rx_mb=network_rx_mb, network_tx_mb=network_tx_mb, disk_read_mb=disk_read_mb, disk_write_mb=disk_write_mb, timestamp=datetime.now().isoformat() ) except Exception as e: logger.error(f"Error getting container metrics for {container_id}: {e}") return ServiceMetrics() def _parse_size_to_mb(self, size_str: str) -> float: """Parse Docker size string to MB""" try: size_str = size_str.strip() if size_str.endswith('B'): size_str = size_str[:-1] if size_str.endswith('k') or size_str.endswith('K'): return float(size_str[:-1]) / 1024 elif size_str.endswith('M'): return float(size_str[:-1]) elif size_str.endswith('G'): return float(size_str[:-1]) * 1024 elif size_str.endswith('T'): return float(size_str[:-1]) * 1024 * 1024 else: return float(size_str) / (1024 * 1024) # Assume bytes except Exception: return 0.0 def _get_container_logs(self, container_id: str, lines: int = 10) -> List[str]: """Get recent logs from a Docker container""" try: if not container_id: return [] result = subprocess.run( ['docker', 'logs', '--tail', str(lines), container_id], capture_output=True, text=True, timeout=5 ) if result.returncode == 0: return result.stdout.strip().split('\n')[-lines:] else: return result.stderr.strip().split('\n')[-lines:] if result.stderr else [] except Exception as e: logger.error(f"Error getting container logs for {container_id}: {e}") return [] def _parse_docker_ports(self, ports_str: str) -> List[Dict[str, str]]: """Parse Docker port mappings""" if not ports_str: return [] ports = [] for part in ports_str.split(', '): if '->' in part: external_part, internal_part = part.split('->') external_port = external_part.split(':')[-1] internal_port = internal_part.split('/')[0] protocol = internal_part.split('/')[1] if '/' in internal_part else 'tcp' ports.append({ 'external': external_port, 'internal': internal_port, 'protocol': protocol }) return ports def _get_service_url(self, ports: List[Dict]) -> Optional[str]: """Get the primary URL for a service""" for port_info in ports: if port_info.get('external'): try: port_num = int(port_info['external']) if 80 <= port_num <= 9999: # Web service ports return f"http://localhost:{port_num}" except ValueError: pass return None def get_homebrew_services(self) -> List[ServiceStatus]: """Get Homebrew services status with enhanced error detection""" services = [] try: result = subprocess.run( ['brew', 'services', 'list'], capture_output=True, text=True, timeout=10 ) if result.returncode == 0: lines = result.stdout.strip().split('\n')[1:] # Skip header for line in lines: parts = line.split() if len(parts) >= 3: name = parts[0] status = parts[1] service_def = self.service_definitions.get(name, {}) # Enhanced status detection and log capture service_logs = [] # Check if process is actually running (for cases like dnsmasq) is_process_running = self._check_process_running(name) # Determine health status with better logic if status == 'started' or (status == 'error' and is_process_running): service_status = 'running' health_score = 100 if status == 'error' and is_process_running: service_logs.append(f"Service reports error status but process is running (PID found)") elif status == 'stopped': service_status = 'stopped' health_score = 0 elif 'error' in status: service_status = 'error' health_score = 10 # Get detailed error logs error_logs = self._get_service_error_logs(name) service_logs.extend(error_logs) else: service_status = 'unknown' health_score = 50 # Get service URLs if available service_url = None if name == 'caddy': service_url = "http://home.samcloud.ca" elif name == 'nginx': service_url = "http://localhost:80" service = ServiceStatus( name=name, display_name=service_def.get('display_name', name.title()), category=service_def.get('category', '🍺 Homebrew'), icon=service_def.get('icon', '🍺'), color=service_def.get('color', '#fbb040'), status=service_status, health_score=health_score, url=service_url, description=service_def.get('description', f'Homebrew service: {name}'), container_id=None, image=None, ports=self._get_service_ports(name), environment='homebrew', metrics=ServiceMetrics(), logs=service_logs, dependencies=service_def.get('dependencies', []), last_updated=datetime.now().isoformat() ) services.append(service) except Exception as e: logger.error(f"Error getting Homebrew services: {e}") return services def _check_process_running(self, service_name: str) -> bool: """Check if a process is actually running by name""" try: result = subprocess.run( ['pgrep', '-f', service_name], capture_output=True, text=True, timeout=5 ) return result.returncode == 0 and bool(result.stdout.strip()) except: return False def _get_service_error_logs(self, service_name: str) -> List[str]: """Get error logs for a specific service""" logs = [] # Common log locations log_paths = [ f'/opt/homebrew/var/log/{service_name}.log', f'/opt/homebrew/var/log/{service_name}/error.log', f'/usr/local/var/log/{service_name}.log', f'/usr/local/var/log/{service_name}/error.log' ] # Service-specific log locations if service_name == 'nginx': log_paths.extend([ '/opt/homebrew/var/log/nginx/error.log', '/usr/local/var/log/nginx/error.log' ]) elif service_name == 'dnsmasq': log_paths.extend([ '/opt/homebrew/var/log/dnsmasq.log', '/usr/local/var/log/dnsmasq.log' ]) for log_path in log_paths: try: if os.path.exists(log_path): result = subprocess.run( ['tail', '-5', log_path], capture_output=True, text=True, timeout=5 ) if result.returncode == 0 and result.stdout.strip(): logs.extend(result.stdout.strip().split('\n')) break except: continue # Special handling for nginx port conflicts if service_name == 'nginx' and not logs: try: result = subprocess.run( ['lsof', '-i', ':80'], capture_output=True, text=True, timeout=5 ) if result.returncode == 0: port_users = result.stdout.strip().split('\n')[1:] # Skip header if port_users: logs.append("Port 80 conflict detected:") for user in port_users[:3]: # Show first 3 logs.append(f" {user}") except: logs.append("Unable to check port 80 status") return logs[:10] # Limit to 10 recent log entries def _get_service_ports(self, service_name: str) -> List[Dict]: """Get ports used by a service""" ports = [] if service_name == 'nginx': ports = [{'port': 80, 'protocol': 'HTTP'}, {'port': 443, 'protocol': 'HTTPS'}] elif service_name == 'caddy': ports = [{'port': 80, 'protocol': 'HTTP'}, {'port': 443, 'protocol': 'HTTPS'}] elif service_name == 'dnsmasq': ports = [{'port': 53, 'protocol': 'DNS'}] elif service_name == 'postgresql': ports = [{'port': 5432, 'protocol': 'PostgreSQL'}] elif service_name == 'ollama': ports = [{'port': 11434, 'protocol': 'HTTP'}] return ports def get_native_services(self) -> List[ServiceStatus]: """Get native macOS services (PostgreSQL, etc.)""" services = [] # Check for PostgreSQL try: result = subprocess.run( ['pgrep', '-f', 'postgres'], capture_output=True, text=True, timeout=5 ) if result.returncode == 0: service_def = self.service_definitions.get('postgresql', {}) service = ServiceStatus( name='postgresql', display_name=service_def.get('display_name', 'PostgreSQL'), category=service_def.get('category', 'πŸ”§ Infrastructure'), icon=service_def.get('icon', '🐘'), color=service_def.get('color', '#336791'), status='running', health_score=100, url=None, description=service_def.get('description', 'PostgreSQL database server'), container_id=None, image=None, ports=[], environment='native', metrics=ServiceMetrics(), logs=[], dependencies=[], last_updated=datetime.now().isoformat() ) services.append(service) except Exception as e: logger.error(f"Error checking PostgreSQL: {e}") return services def get_all_services(self) -> List[ServiceStatus]: """Get all services from all environments""" all_services = [] # Docker services all_services.extend(self.get_docker_services()) # Homebrew services all_services.extend(self.get_homebrew_services()) # Native services all_services.extend(self.get_native_services()) # Sort by category then name all_services.sort(key=lambda x: (x.category, x.display_name)) return all_services def get_ddns_status(self) -> Dict[str, Any]: """Get DDNS status from existing monitor""" try: result = subprocess.run( ['python3', self.ddns_monitor_path, '--json'], capture_output=True, text=True, timeout=10 ) if result.returncode == 0: for line in result.stdout.strip().split('\n'): line = line.strip() if line.startswith('{') and line.endswith('}'): try: return json.loads(line) except json.JSONDecodeError: continue return { 'status': 'healthy' if result.returncode == 0 else 'error', 'domain': 'wg.samcloud.ca', 'healthy': result.returncode == 0, 'last_check': datetime.now().isoformat() } except Exception as e: return { 'status': 'error', 'error': str(e), 'healthy': False, 'last_check': datetime.now().isoformat() } def restart_service(self, service_name: str, environment: str) -> Dict[str, Any]: """Restart a service""" try: if environment == 'docker': result = subprocess.run( ['docker', 'restart', service_name], capture_output=True, text=True, timeout=30 ) return { 'success': result.returncode == 0, 'message': f"Service {service_name} restart {'successful' if result.returncode == 0 else 'failed'}", 'output': result.stdout or result.stderr } elif environment == 'homebrew': result = subprocess.run( ['brew', 'services', 'restart', service_name], capture_output=True, text=True, timeout=30 ) return { 'success': result.returncode == 0, 'message': f"Homebrew service {service_name} restart {'successful' if result.returncode == 0 else 'failed'}", 'output': result.stdout or result.stderr } else: return { 'success': False, 'message': f"Restart not supported for environment: {environment}" } except Exception as e: return { 'success': False, 'message': f"Error restarting service: {str(e)}" } def get_dashboard_data(self) -> Dict[str, Any]: """Get complete dashboard data""" return { 'system': self.get_system_metrics(), 'services': [asdict(s) for s in self.get_all_services()], 'ddns': self.get_ddns_status(), 'metrics_history': dict(self.metrics_history), 'meta': { 'dashboard_uptime': str(datetime.now() - self.start_time).split('.')[0], 'last_refresh': datetime.now().isoformat(), 'version': '3.0.0-enterprise', 'metrics_enabled': self.metrics_running } } # Global dashboard instance homelab = AdvancedHomeLab() # Start metrics collection on initialization homelab.start_metrics_collection() # Continue with Flask routes and WebSocket handlers... # [The rest of the file would contain the Flask app routes and the sophisticated HTML/CSS/JS frontend]