#!/usr/bin/env python3 """ SamCloud Enterprise Dashboard - Next Generation ================================================================ A truly dynamic, comprehensive home lab monitoring solution Built specifically for macOS 15.3.2 Sequoia with complete service discovery Features: - Real-time service discovery (Docker, OrbStack, Kubernetes, native macOS) - Enterprise-grade monitoring with Prometheus integration - Apple HIG 2025 compliant design with zero-scroll layout - Interactive service management and health monitoring - Multi-source aggregation (containers, native apps, brew services) """ 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, Union 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 import re # Configure logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) app = Flask(__name__) app.config['SECRET_KEY'] = 'samcloud-enterprise-secret' CORS(app) socketio = SocketIO(app, cors_allowed_origins="*") @dataclass class ServiceHealth: """Service health metrics and status""" name: str display_name: str category: str icon: str color: str status: str # running, stopped, degraded, unknown, error health_score: int # 0-100 uptime: str cpu_percent: float memory_mb: float memory_percent: float ports: List[Dict[str, Union[str, int]]] url: Optional[str] api_endpoint: Optional[str] logs: List[str] last_check: str response_time: Optional[float] environment: str # docker, native, kubernetes, homebrew container_id: Optional[str] image: Optional[str] pid: Optional[int] dependencies: List[str] alerts: List[str] class MacOSServiceDiscovery: """Advanced service discovery for macOS systems""" def __init__(self): self.start_time = datetime.now() self.services_cache = {} self.last_scan = None self.metrics_history = defaultdict(lambda: deque(maxlen=100)) # Known service configurations with health endpoints self.service_configs = { # Media Automation Stack 'sonarr': { 'display_name': 'Sonarr', 'category': 'π¬ Media Automation', 'icon': 'πΊ', 'color': '#2196f3', 'ports': [8989], 'api_path': '/api/v3/system/status', 'api_key_env': 'SONARR_API_KEY' }, 'radarr': { 'display_name': 'Radarr', 'category': 'π¬ Media Automation', 'icon': 'π¬', 'color': '#ffc107', 'ports': [7878], 'api_path': '/api/v3/system/status', 'api_key_env': 'RADARR_API_KEY' }, 'lidarr': { 'display_name': 'Lidarr', 'category': 'π¬ Media Automation', 'icon': 'π΅', 'color': '#9c27b0', 'ports': [8686], 'api_path': '/api/v1/system/status', 'api_key_env': 'LIDARR_API_KEY' }, 'bazarr': { 'display_name': 'Bazarr', 'category': 'π¬ Media Automation', 'icon': 'π¬', 'color': '#607d8b', 'ports': [6767], 'api_path': '/api/system/status', 'api_key_env': 'BAZARR_API_KEY' }, 'prowlarr': { 'display_name': 'Prowlarr', 'category': 'π Indexers', 'icon': 'π', 'color': '#795548', 'ports': [9696], 'api_path': '/api/v1/system/status', 'api_key_env': 'PROWLARR_API_KEY' }, 'jackett': { 'display_name': 'Jackett', 'category': 'π Indexers', 'icon': 'π§₯', 'color': '#ff9800', 'ports': [9117], 'api_path': '/api/v2.0/server/config' }, 'jellyseerr': { 'display_name': 'Jellyseerr', 'category': 'π Requests', 'icon': 'π', 'color': '#7c4dff', 'ports': [5055], 'api_path': '/api/v1/status' }, # Download Clients 'qbittorrent': { 'display_name': 'qBittorrent', 'category': 'β¬οΈ Downloads', 'icon': 'π', 'color': '#2196f3', 'ports': [8080], 'api_path': '/api/v2/app/version', 'login_required': True }, # Media Servers 'jellyfin': { 'display_name': 'Jellyfin', 'category': 'π₯ Media Servers', 'icon': 'π', 'color': '#00a4dc', 'ports': [8096], 'api_path': '/System/Info', 'native_app': True }, 'navidrome': { 'display_name': 'Navidrome', 'category': 'π΅ Music', 'icon': 'πΌ', 'color': '#663399', 'ports': [4533], 'api_path': '/api/ping' }, # AI/ML Services 'openwebui': { 'display_name': 'Open WebUI', 'category': 'π€ AI/ML', 'icon': 'π§ ', 'color': '#9c27b0', 'ports': [3000], 'api_path': '/api/v1/models' }, 'ollama': { 'display_name': 'Ollama', 'category': 'π€ AI/ML', 'icon': 'π¦', 'color': '#ff6b35', 'ports': [11434], 'api_path': '/api/version' }, # Infrastructure 'uptime-kuma': { 'display_name': 'Uptime Kuma', 'category': 'π Monitoring', 'icon': 'π', 'color': '#ff9800', 'ports': [3001], 'api_path': '/api/status-page' }, 'nginx-proxy-manager': { 'display_name': 'Nginx Proxy Manager', 'category': 'π§ Infrastructure', 'icon': 'π', 'color': '#009639', 'ports': [81], 'api_path': '/api' }, 'filebrowser': { 'display_name': 'File Browser', 'category': 'π Files', 'icon': 'π', 'color': '#607d8b', 'ports': [8833], 'api_path': '/api/public/settings' }, 'immich': { 'display_name': 'Immich', 'category': 'π· Photos', 'icon': 'πΈ', 'color': '#4285f4', 'ports': [2283], 'api_path': '/api/server-info' }, 'homepage': { 'display_name': 'Homepage', 'category': 'π Dashboards', 'icon': 'π ', 'color': '#4caf50', 'ports': [10001], 'api_path': '/api/config' }, 'dashy': { 'display_name': 'Dashy', 'category': 'π Dashboards', 'icon': 'ποΈ', 'color': '#00af87', 'ports': [8081], 'api_path': '/status' }, 'spotspot': { 'display_name': 'SpotSpot', 'category': 'π΅ Music', 'icon': 'π§', 'color': '#1db954', 'ports': [6544], 'api_path': '/health' }, # Homebrew Services 'nginx': { 'display_name': 'Nginx', 'category': 'π§ Infrastructure', 'icon': 'π', 'color': '#009639', 'ports': [80, 443], 'homebrew': True }, 'dnsmasq': { 'display_name': 'DNSmasq', 'category': 'π Network', 'icon': 'ποΈ', 'color': '#2196f3', 'ports': [53], 'homebrew': True }, 'caddy': { 'display_name': 'Caddy', 'category': 'π§ Infrastructure', 'icon': 'πΈοΈ', 'color': '#1f88e5', 'ports': [80, 443], 'homebrew': True }, 'postgresql': { 'display_name': 'PostgreSQL', 'category': 'ποΈ Databases', 'icon': 'π', 'color': '#336791', 'ports': [5432], 'homebrew': True } } async def discover_all_services(self) -> List[ServiceHealth]: """Comprehensive service discovery across all environments""" services = [] # Run discovery methods in parallel tasks = [ self.discover_docker_services(), self.discover_native_services(), self.discover_homebrew_services(), self.discover_kubernetes_services(), self.discover_network_services() ] results = await asyncio.gather(*tasks, return_exceptions=True) for result in results: if isinstance(result, list): services.extend(result) elif isinstance(result, Exception): logger.error(f"Service discovery error: {result}") # Deduplicate and enrich services unique_services = self._deduplicate_services(services) enriched_services = await self._enrich_services_with_health(unique_services) self.last_scan = datetime.now() return enriched_services async def discover_docker_services(self) -> List[ServiceHealth]: """Discover Docker/OrbStack containers""" services = [] try: # Get all containers (running and stopped) result = subprocess.run( ['docker', 'ps', '-a', '--format', 'json'], capture_output=True, text=True, timeout=10 ) if result.returncode == 0: for line in result.stdout.strip().split('\n'): if line.strip(): container = json.loads(line) service = await self._process_docker_container(container) if service: services.append(service) except Exception as e: logger.error(f"Docker discovery error: {e}") return services async def discover_native_services(self) -> List[ServiceHealth]: """Discover native macOS applications and services""" services = [] # Check for Jellyfin native app try: result = subprocess.run( ['ps', 'aux'], capture_output=True, text=True, timeout=5 ) if result.returncode == 0: for line in result.stdout.split('\n'): if 'jellyfin' in line.lower() and 'grep' not in line: service = await self._process_native_app(line, 'jellyfin') if service: services.append(service) except Exception as e: logger.error(f"Native service discovery error: {e}") return services async def discover_homebrew_services(self) -> List[ServiceHealth]: """Discover Homebrew services 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: service = await self._process_homebrew_service(parts) if service: services.append(service) except Exception as e: logger.error(f"Homebrew discovery error: {e}") return services async def discover_kubernetes_services(self) -> List[ServiceHealth]: """Discover Kubernetes services (if any)""" services = [] # Implementation for K8s discovery if needed return services async def discover_network_services(self) -> List[ServiceHealth]: """Discover services by network scanning""" services = [] try: # Get listening ports result = subprocess.run( ['lsof', '-iTCP', '-sTCP:LISTEN'], capture_output=True, text=True, timeout=10 ) if result.returncode == 0: for line in result.stdout.split('\n')[1:]: # Skip header if line.strip(): service = await self._process_network_service(line) if service: services.append(service) except Exception as e: logger.error(f"Network discovery error: {e}") return services async def _process_docker_container(self, container: Dict) -> Optional[ServiceHealth]: """Process a Docker container into a ServiceHealth object""" name = container.get('Names', '').lower() image = container.get('Image', '') status = container.get('Status', '') # Skip Kubernetes system containers if name.startswith('k8s_') and 'kube-system' in name: return None # Find matching service config service_config = None for key, config in self.service_configs.items(): if key in name or name.replace('-', '').replace('_', '') == key.replace('-', '').replace('_', ''): service_config = config break if not service_config: # Create generic service for unknown containers service_config = { 'display_name': name.title().replace('-', ' ').replace('_', ' '), 'category': 'π³ Containers', 'icon': 'βοΈ', 'color': '#607d8b', 'ports': [] } # Parse ports ports = self._parse_container_ports(container.get('Ports', '')) # Determine health status is_running = 'Up' in status is_healthy = 'healthy' in status health_score = 100 if is_healthy else (75 if is_running else 0) return ServiceHealth( name=name, display_name=service_config['display_name'], category=service_config['category'], icon=service_config['icon'], color=service_config['color'], status='running' if is_running else 'stopped', health_score=health_score, uptime=self._parse_uptime(status), cpu_percent=0.0, memory_mb=0.0, memory_percent=0.0, ports=ports, url=self._generate_service_url(ports), api_endpoint=service_config.get('api_path'), logs=[], last_check=datetime.now().isoformat(), response_time=None, environment='docker', container_id=container.get('ID'), image=image, pid=None, dependencies=service_config.get('dependencies', []), alerts=[] ) async def _process_homebrew_service(self, parts: List[str]) -> Optional[ServiceHealth]: """Process a Homebrew service""" name = parts[0] status = parts[1] service_config = self.service_configs.get(name, { 'display_name': name.title(), 'category': 'πΊ Homebrew', 'icon': 'πΊ', 'color': '#fbb040', 'ports': [] }) # Check if process is actually running is_process_running = await self._check_process_running(name) # Enhanced status logic if status == 'started' or (status == 'error' and is_process_running): service_status = 'running' health_score = 100 if status == 'started' else 75 elif status == 'stopped': service_status = 'stopped' health_score = 0 elif 'error' in status: service_status = 'error' health_score = 10 else: service_status = status health_score = 25 # Get error logs if needed logs = [] if 'error' in status or not is_process_running: logs = await self._get_service_logs(name) ports_info = [] if 'ports' in service_config: for port in service_config['ports']: ports_info.append({ 'internal': port, 'external': port, 'protocol': 'tcp' }) return ServiceHealth( name=name, display_name=service_config['display_name'], category=service_config['category'], icon=service_config['icon'], color=service_config['color'], status=service_status, health_score=health_score, uptime='', cpu_percent=0.0, memory_mb=0.0, memory_percent=0.0, ports=ports_info, url=self._generate_service_url(ports_info), api_endpoint=service_config.get('api_path'), logs=logs, last_check=datetime.now().isoformat(), response_time=None, environment='homebrew', container_id=None, image='homebrew', pid=None, dependencies=service_config.get('dependencies', []), alerts=[] ) async def _process_native_app(self, process_line: str, app_name: str) -> Optional[ServiceHealth]: """Process a native macOS application""" service_config = self.service_configs.get(app_name, {}) if not service_config: return None # Extract PID parts = process_line.split() pid = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None ports_info = [] if 'ports' in service_config: for port in service_config['ports']: ports_info.append({ 'internal': port, 'external': port, 'protocol': 'tcp' }) return ServiceHealth( name=app_name, display_name=service_config['display_name'], category=service_config['category'], icon=service_config['icon'], color=service_config['color'], status='running', health_score=100, uptime='', cpu_percent=0.0, memory_mb=0.0, memory_percent=0.0, ports=ports_info, url=self._generate_service_url(ports_info), api_endpoint=service_config.get('api_path'), logs=[], last_check=datetime.now().isoformat(), response_time=None, environment='native', container_id=None, image='native-macos', pid=pid, dependencies=service_config.get('dependencies', []), alerts=[] ) async def _enrich_services_with_health(self, services: List[ServiceHealth]) -> List[ServiceHealth]: """Enrich services with real-time health data""" enriched = [] for service in services: try: # Test API endpoints if service.api_endpoint and service.url: health_data = await self._test_service_health(service) service.response_time = health_data.get('response_time') if health_data.get('healthy'): service.health_score = min(100, service.health_score + 10) else: service.health_score = max(0, service.health_score - 20) # Get resource usage if service.pid: try: proc = psutil.Process(service.pid) service.cpu_percent = proc.cpu_percent() service.memory_mb = proc.memory_info().rss / 1024 / 1024 service.memory_percent = proc.memory_percent() except: pass enriched.append(service) except Exception as e: logger.error(f"Error enriching service {service.name}: {e}") enriched.append(service) return enriched async def _test_service_health(self, service: ServiceHealth) -> Dict[str, Any]: """Test service health via API endpoint""" try: if not service.url or not service.api_endpoint: return {'healthy': False, 'response_time': None} url = f"{service.url}{service.api_endpoint}" start_time = time.time() response = requests.get(url, timeout=5) response_time = (time.time() - start_time) * 1000 # ms return { 'healthy': response.status_code < 400, 'response_time': response_time, 'status_code': response.status_code } except Exception as e: return {'healthy': False, 'response_time': None, 'error': str(e)} def _deduplicate_services(self, services: List[ServiceHealth]) -> List[ServiceHealth]: """Remove duplicate services""" seen = set() unique = [] for service in services: key = (service.name, service.environment) if key not in seen: seen.add(key) unique.append(service) return unique def _parse_container_ports(self, ports_str: str) -> List[Dict[str, Union[str, int]]]: """Parse Docker container ports string""" ports = [] if not ports_str: return ports # Parse port mappings like "0.0.0.0:3001->3001/tcp" port_pattern = r'(\d+\.\d+\.\d+\.\d+:)?(\d+)->(\d+)/(tcp|udp)' matches = re.findall(port_pattern, ports_str) for match in matches: external_port = int(match[1]) if match[1] else None internal_port = int(match[2]) protocol = match[3] ports.append({ 'external': external_port, 'internal': internal_port, 'protocol': protocol }) return ports def _generate_service_url(self, ports: List[Dict]) -> Optional[str]: """Generate service URL from port information""" if not ports: return None # Find HTTP port for port in ports: external_port = port.get('external') if external_port and port.get('protocol') == 'tcp': return f"http://localhost:{external_port}" return None def _parse_uptime(self, status: str) -> str: """Parse uptime from Docker status""" if 'Up' in status: return status.replace('Up ', '').split(' (')[0] return '0s' async def _check_process_running(self, service_name: str) -> bool: """Check if a process is running""" 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 async def _get_service_logs(self, service_name: str) -> List[str]: """Get recent error logs for a service""" logs = [] 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' ] for log_path in log_paths: try: if os.path.exists(log_path): result = subprocess.run( ['tail', '-3', log_path], capture_output=True, text=True, timeout=3 ) if result.returncode == 0: log_lines = result.stdout.strip().split('\n') logs.extend([line for line in log_lines if line.strip()]) break except: continue return logs[:5] # Global service discovery instance discovery = MacOSServiceDiscovery() @app.route('/') def dashboard(): """Main dashboard interface""" return render_template_string(DASHBOARD_HTML) @app.route('/api/services') async def get_services(): """Get all discovered services""" try: services = await discovery.discover_all_services() return jsonify([asdict(service) for service in services]) except Exception as e: logger.error(f"Error getting services: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/system') def get_system_info(): """Get system information""" try: cpu_percent = psutil.cpu_percent(interval=1) memory = psutil.virtual_memory() disk = psutil.disk_usage('/') return jsonify({ 'hostname': socket.gethostname(), 'platform': platform.platform(), 'architecture': platform.machine(), 'cpu_count': psutil.cpu_count(), 'cpu_percent': cpu_percent, 'memory_total_gb': round(memory.total / (1024**3), 2), 'memory_used_gb': round(memory.used / (1024**3), 2), 'memory_percent': memory.percent, 'disk_total_gb': round(disk.total / (1024**3), 2), 'disk_used_gb': round(disk.used / (1024**3), 2), 'disk_percent': round((disk.used / disk.total) * 100, 1), 'uptime': str(datetime.now() - discovery.start_time).split('.')[0], 'last_scan': discovery.last_scan.isoformat() if discovery.last_scan else None }) except Exception as e: return jsonify({'error': str(e)}), 500 @socketio.on('connect') def handle_connect(): """Handle client connection""" logger.info('Client connected') emit('status', {'message': 'Connected to SamCloud Dashboard'}) @socketio.on('disconnect') def handle_disconnect(): """Handle client disconnect""" logger.info('Client disconnected') # Dashboard HTML Template with modern Apple-like design DASHBOARD_HTML = '''