#!/usr/bin/env python3 """ SamCloud Dashboard - Modern Flask Application A unified Apple-styled web application for Mac Mini server management """ from flask import Flask, render_template, request, jsonify, session, redirect, url_for, send_file import os import json import subprocess import time import shutil import psutil from datetime import datetime import logging # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) app = Flask(__name__) app.secret_key = 'samcloud-dashboard-secret-key-2025' # In production, use environment variable # Configuration class Config: DEBUG = False # Disable debug mode for stability HOST = '0.0.0.0' PORT = 8383 # Paths BASE_DIR = os.path.dirname(os.path.abspath(__file__)) STATIC_DIR = os.path.join(BASE_DIR, 'static') TEMPLATES_DIR = os.path.join(BASE_DIR, 'templates') config = Config() # Create directories if they don't exist os.makedirs(config.STATIC_DIR, exist_ok=True) os.makedirs(config.TEMPLATES_DIR, exist_ok=True) # Simple authentication (for development - replace with proper auth in production) USERS = { 'admin': 'samcloud2025', 'sammacmini': 'admin' # Add your Mac username here } def get_connected_devices(): """Get connected devices on the network including screen sharing connections""" try: devices = [] # Method 1: ARP table for network devices try: arp_result = subprocess.run(['arp', '-a'], capture_output=True, text=True, timeout=3) if arp_result.returncode == 0: lines = arp_result.stdout.strip().split('\n') for line in lines: if '(' in line and ')' in line and 'incomplete' not in line: try: # Parse ARP output: hostname (ip) at mac_address parts = line.split() if len(parts) >= 4: hostname = parts[0] if parts[0] != '?' else 'Unknown Device' ip = parts[1].strip('()') mac = parts[3] if len(parts) > 3 else 'Unknown' # Enhanced device type detection device_type = "Unknown Device" device_icon = "🖥️" if mac != 'Unknown' and mac != 'incomplete': mac_upper = mac.upper() # Apple device patterns (more comprehensive) apple_prefixes = [ '00:1B:63', '00:25:00', 'D8:30:62', '3C:15:C2', '00:23:DF', '00:26:08', '00:03:93', 'AC:DE:48', 'B8:09:8A', 'B8:C7:5D', '14:BD:61', '8C:85:90', 'F0:DB:C1', '38:C9:86', '6C:40:08', 'A4:5E:60', 'F4:0F:24', '2C:B4:3A', '5C:F9:38', '60:C5:47' ] if any(mac_upper.startswith(prefix) for prefix in apple_prefixes): device_type = "Apple Device" device_icon = "🍎" # Try to determine specific Apple device type try: ping_result = subprocess.run(['ping', '-c', '1', '-W', '1000', ip], capture_output=True, timeout=2) if ping_result.returncode == 0: # Device is responding, likely active if 'macbook' in hostname.lower(): device_type = "MacBook" device_icon = "💻" elif 'imac' in hostname.lower(): device_type = "iMac" device_icon = "🖥️" elif 'iphone' in hostname.lower(): device_type = "iPhone" device_icon = "📱" elif 'ipad' in hostname.lower(): device_type = "iPad" device_icon = "📱" except: pass elif mac_upper.startswith(('B8:27:EB', 'DC:A6:32', 'E4:5F:01')): device_type = "Raspberry Pi" device_icon = "🥧" else: # Check for other common manufacturers if mac_upper.startswith(('00:50:56', '00:0C:29')): # VMware device_type = "Virtual Machine" device_icon = "💻" elif mac_upper.startswith(('08:00:27')): # VirtualBox device_type = "Virtual Machine" device_icon = "💻" else: device_type = "Network Device" device_icon = "🌐" devices.append({ 'hostname': hostname, 'ip': ip, 'mac': mac, 'type': device_type, 'icon': device_icon, 'status': 'Connected', 'last_seen': datetime.now().isoformat() }) except (IndexError, ValueError): continue except subprocess.TimeoutExpired: logger.warning("ARP command timed out") # Add current active connections as fallback try: # Get network connections to find active devices netstat_result = subprocess.run(['netstat', '-rn'], capture_output=True, text=True, timeout=2) if netstat_result.returncode == 0: # Look for default gateway and add it as a device lines = netstat_result.stdout.split('\n') for line in lines: if 'default' in line: parts = line.split() if len(parts) > 1: gateway_ip = parts[1] devices.append({ 'hostname': 'Network Gateway', 'ip': gateway_ip, 'mac': 'Unknown', 'type': 'Router', 'icon': '🌐', 'status': 'Connected', 'last_seen': datetime.now().isoformat() }) break except: pass except Exception as e: logger.error(f"Error getting ARP table: {e}") # Method 2: Check for active screen sharing connections try: # Check for Screen Sharing (VNC) connections vnc_result = subprocess.run(['lsof', '-i', ':5900'], capture_output=True, text=True, timeout=5) if vnc_result.returncode == 0 and vnc_result.stdout.strip(): lines = vnc_result.stdout.strip().split('\n')[1:] # Skip header for line in lines: if 'ESTABLISHED' in line: parts = line.split() if len(parts) >= 9: connection = parts[8] # Format: ip:port->ip:port if '->' in connection: remote_ip = connection.split('->')[0].split(':')[0] # Add screen sharing device if not already in devices if not any(d['ip'] == remote_ip for d in devices): devices.append({ 'hostname': f'Screen Sharing Client', 'ip': remote_ip, 'mac': 'Unknown', 'type': 'Screen Sharing', 'icon': '🖥️', 'status': 'Screen Sharing Active', 'last_seen': datetime.now().isoformat() }) except Exception as e: logger.error(f"Error checking screen sharing: {e}") # Method 3: Check SSH connections try: ssh_result = subprocess.run(['lsof', '-i', ':22'], capture_output=True, text=True, timeout=5) if ssh_result.returncode == 0 and ssh_result.stdout.strip(): lines = ssh_result.stdout.strip().split('\n')[1:] # Skip header for line in lines: if 'ESTABLISHED' in line: parts = line.split() if len(parts) >= 9: connection = parts[8] if '->' in connection: remote_ip = connection.split('->')[0].split(':')[0] if not any(d['ip'] == remote_ip for d in devices): devices.append({ 'hostname': 'SSH Client', 'ip': remote_ip, 'mac': 'Unknown', 'type': 'SSH Connection', 'icon': '💻', 'status': 'SSH Active', 'last_seen': datetime.now().isoformat() }) except Exception as e: logger.error(f"Error checking SSH connections: {e}") # Method 4: Add devices based on active HTTP connections try: # Check for HTTP connections on port 8383 (this dashboard) http_result = subprocess.run(['lsof', '-i', ':8383'], capture_output=True, text=True, timeout=3) if http_result.returncode == 0 and http_result.stdout.strip(): lines = http_result.stdout.strip().split('\n')[1:] # Skip header for line in lines: if 'ESTABLISHED' in line: parts = line.split() if len(parts) >= 9: connection = parts[8] if '->' in connection: remote_ip = connection.split('->')[0].split(':')[0] if not any(d['ip'] == remote_ip for d in devices): # Determine device type based on IP pattern or other indicators device_type = 'Web Client' device_icon = '💻' hostname = 'Dashboard Client' # Check if this is a macOS device by IP pattern if remote_ip.startswith('192.168.100.90'): device_type = 'MacBook' device_icon = '💻' hostname = 'MacBook (Dashboard Client)' elif remote_ip.startswith('192.168.100.22'): device_type = 'Mac mini' device_icon = '🖥️' hostname = 'Mac mini (Local)' devices.append({ 'hostname': hostname, 'ip': remote_ip, 'mac': 'Unknown', 'type': device_type, 'icon': device_icon, 'status': 'Dashboard Active', 'last_seen': datetime.now().isoformat() }) except Exception as e: logger.error(f"Error checking HTTP connections: {e}") # Get network statistics net_io = psutil.net_io_counters() # Sort devices by IP for consistent ordering devices.sort(key=lambda x: x['ip']) return { 'devices': devices[:20], # Show up to 20 devices 'total_devices': len(devices), 'network_stats': { 'bytes_sent': net_io.bytes_sent, 'bytes_recv': net_io.bytes_recv, 'packets_sent': net_io.packets_sent, 'packets_recv': net_io.packets_recv } } except Exception as e: logger.error(f"Error getting connected devices: {e}") return {'devices': [], 'total_devices': 0, 'network_stats': {}} def get_mac_model_info(): """Get actual Mac model information""" try: # Get Mac model from system_profiler result = subprocess.run(['system_profiler', 'SPHardwareDataType'], capture_output=True, text=True, timeout=10) mac_info = { 'model': 'Mac', 'year': '', 'processor': 'Unknown Processor', 'memory': '0 GB', 'serial': 'Unknown', 'identifier': 'Unknown' } if result.returncode == 0: lines = result.stdout.split('\n') for line in lines: line = line.strip() if 'Model Name:' in line: mac_info['model'] = line.split(':', 1)[1].strip() elif 'Model Identifier:' in line: mac_info['identifier'] = line.split(':', 1)[1].strip() elif 'Processor Name:' in line or 'Chip:' in line: mac_info['processor'] = line.split(':', 1)[1].strip() elif 'Memory:' in line: mac_info['memory'] = line.split(':', 1)[1].strip() elif 'Serial Number' in line: mac_info['serial'] = line.split(':', 1)[1].strip() # Extract year from model if possible import re year_match = re.search(r'\((\d{4})\)', mac_info['model']) if year_match: mac_info['year'] = year_match.group(1) return mac_info except Exception as e: logger.error(f"Error getting Mac info: {e}") return { 'model': 'Mac', 'year': '', 'processor': 'Unknown Processor', 'memory': '0 GB', 'serial': 'Unknown', 'identifier': 'Unknown' } def get_macos_version(): """Get macOS version information""" try: result = subprocess.run(['sw_vers'], capture_output=True, text=True, timeout=5) version_info = { 'name': 'macOS', 'version': 'Unknown', 'build': 'Unknown' } if result.returncode == 0: lines = result.stdout.split('\n') for line in lines: if 'ProductName:' in line: version_info['name'] = line.split(':', 1)[1].strip() elif 'ProductVersion:' in line: version_info['version'] = line.split(':', 1)[1].strip() elif 'BuildVersion:' in line: version_info['build'] = line.split(':', 1)[1].strip() return version_info except Exception as e: logger.error(f"Error getting macOS version: {e}") return {'name': 'macOS', 'version': 'Unknown', 'build': 'Unknown'} def get_system_info(): """Get comprehensive system information""" try: # CPU and Memory with real-time data cpu_percent = psutil.cpu_percent(interval=0.1) # Faster response cpu_freq = psutil.cpu_freq() memory = psutil.virtual_memory() # Get Mac-specific info mac_info = get_mac_model_info() macos_info = get_macos_version() # Disk usage with better filtering disks = [] for partition in psutil.disk_partitions(): try: # Skip system partitions that shouldn't be shown if (partition.mountpoint.startswith('/System') or partition.mountpoint.startswith('/private') or partition.mountpoint in ['/dev', '/proc', '/sys']): continue usage = psutil.disk_usage(partition.mountpoint) # Only show disks with significant size (> 100MB) if usage.total > 100 * 1024 * 1024: disks.append({ 'device': partition.device, 'mountpoint': partition.mountpoint, 'fstype': partition.fstype, 'total': usage.total, 'used': usage.used, 'free': usage.free, 'percent': round((usage.used / usage.total) * 100, 1) }) except (PermissionError, FileNotFoundError): continue # Network with current speed calculation network = psutil.net_io_counters() # Get network interfaces for speed calculation net_interfaces = psutil.net_if_stats() network_speed = 0 for interface, stats in net_interfaces.items(): if stats.isup and not interface.startswith('lo'): network_speed = max(network_speed, stats.speed or 0) # Uptime boot_time = psutil.boot_time() uptime = datetime.now() - datetime.fromtimestamp(boot_time) # CPU temperature - use calculated temperature based on load # (Real temperature monitoring requires additional tools/permissions) return { 'mac_info': mac_info, 'macos_info': macos_info, 'cpu': { 'percent': round(cpu_percent, 1), 'cores': psutil.cpu_count(logical=False), 'threads': psutil.cpu_count(logical=True), 'frequency': { 'current': round(cpu_freq.current, 2) if cpu_freq else 0, 'min': round(cpu_freq.min, 2) if cpu_freq else 0, 'max': round(cpu_freq.max, 2) if cpu_freq else 0 } }, 'memory': { 'total': memory.total, 'used': memory.used, 'available': memory.available, 'percent': round(memory.percent, 1), 'free': memory.free, 'cached': getattr(memory, 'cached', 0), 'buffers': getattr(memory, 'buffers', 0) }, 'disks': disks, 'network': { 'bytes_sent': network.bytes_sent, 'bytes_recv': network.bytes_recv, 'packets_sent': network.packets_sent, 'packets_recv': network.packets_recv, 'max_speed': network_speed }, 'uptime': str(uptime).split('.')[0], # Remove microseconds 'uptime_seconds': int(uptime.total_seconds()), 'timestamp': datetime.now().isoformat() } except Exception as e: logger.error(f"Error getting system info: {e}") return {'error': str(e)} def get_docker_containers(): """Get Docker container status""" try: result = subprocess.run(['docker', 'ps', '--format', 'json'], capture_output=True, text=True, timeout=10) if result.returncode == 0: containers = [] for line in result.stdout.strip().split('\n'): if line: containers.append(json.loads(line)) return containers return [] except Exception as e: logger.error(f"Error getting Docker containers: {e}") return [] # Routes @app.route('/') def index(): """Main route - check authentication and serve dashboard""" if 'user' not in session: return redirect(url_for('login')) return render_template('dashboard_enhanced.html') @app.route('/login', methods=['GET', 'POST']) def login(): """Authentication route""" if request.method == 'POST': username = request.form.get('username') password = request.form.get('password') if username in USERS and USERS[username] == password: session['user'] = username return redirect(url_for('index')) else: return render_template('login.html', error='Invalid credentials') return render_template('login.html') @app.route('/logout') def logout(): """Logout route""" session.pop('user', None) return redirect(url_for('login')) # API Routes @app.route('/api/system') def api_system(): """API endpoint for system information""" if 'user' not in session: return jsonify({'error': 'Unauthorized'}), 401 return jsonify(get_system_info()) @app.route('/api/containers') def api_containers(): """API endpoint for Docker containers""" if 'user' not in session: return jsonify({'error': 'Unauthorized'}), 401 return jsonify(get_docker_containers()) @app.route('/api/services') def api_services(): """API endpoint for running services""" if 'user' not in session: return jsonify({'error': 'Unauthorized'}), 401 # Get running services on common ports services = [] common_ports = [80, 443, 3000, 5001, 5055, 8080, 8081, 8383, 8833, 8834, 8989, 9117] for port in common_ports: try: result = subprocess.run(['lsof', f'-i:{port}'], capture_output=True, text=True, timeout=5) if result.returncode == 0 and result.stdout: lines = result.stdout.strip().split('\n')[1:] # Skip header for line in lines: parts = line.split() if len(parts) >= 2: services.append({ 'port': port, 'command': parts[0], 'pid': parts[1], 'status': 'running' }) except: continue return jsonify(services) @app.route('/api/devices') def api_devices(): """API endpoint for connected devices""" if 'user' not in session: return jsonify({'error': 'Unauthorized'}), 401 return jsonify(get_connected_devices()) @app.route('/api/files') def api_files(): """API endpoint for file system browsing""" if 'user' not in session: return jsonify({'error': 'Unauthorized'}), 401 path = request.args.get('path', '/') # Security: Prevent directory traversal if '..' in path or path.startswith('~'): return jsonify({'error': 'Invalid path'}), 400 # Convert to absolute path if path.startswith('/'): abs_path = path else: abs_path = '/' + path.lstrip('/') try: if not os.path.exists(abs_path): return jsonify({'error': 'Path not found'}), 404 if not os.path.isdir(abs_path): return jsonify({'error': 'Path is not a directory'}), 400 files = [] for item in os.listdir(abs_path): item_path = os.path.join(abs_path, item) try: stat_info = os.stat(item_path) is_directory = os.path.isdir(item_path) files.append({ 'name': item, 'size': stat_info.st_size, 'modified': datetime.fromtimestamp(stat_info.st_mtime).isoformat(), 'is_directory': is_directory, 'permissions': oct(stat_info.st_mode)[-3:] }) except (OSError, PermissionError): # Skip files we can't access continue # Sort: directories first, then files, both alphabetically files.sort(key=lambda x: (not x['is_directory'], x['name'].lower())) return jsonify(files) except PermissionError: return jsonify({'error': 'Permission denied'}), 403 except Exception as e: logger.error(f"Error browsing files: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/files/volumes') def api_files_volumes(): """API endpoint to list available volumes""" if 'user' not in session: return jsonify({'error': 'Unauthorized'}), 401 try: volumes = [] volumes_path = '/Volumes' if os.path.exists(volumes_path): for item in os.listdir(volumes_path): item_path = os.path.join(volumes_path, item) if os.path.isdir(item_path) and not item.startswith('.'): try: # Get volume info stat_info = os.stat(item_path) volumes.append({ 'name': item, 'path': item_path, 'mounted': True, 'modified': datetime.fromtimestamp(stat_info.st_mtime).isoformat() }) except (OSError, PermissionError): # Skip volumes we can't access continue # Sort volumes alphabetically volumes.sort(key=lambda x: x['name'].lower()) return jsonify(volumes) except Exception as e: logger.error(f"Error listing volumes: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/files/browse') def api_files_browse(): """API endpoint for browsing files and directories""" if 'user' not in session: return jsonify({'error': 'Unauthorized'}), 401 path = request.args.get('path', '/') # Security: Prevent directory traversal if '..' in path or path.startswith('~'): return jsonify({'error': 'Invalid path'}), 400 try: if not os.path.exists(path): return jsonify({'error': 'Path not found'}), 404 if not os.path.isdir(path): return jsonify({'error': 'Path is not a directory'}), 400 files = [] for item in os.listdir(path): item_path = os.path.join(path, item) try: stat_info = os.stat(item_path) is_directory = os.path.isdir(item_path) files.append({ 'name': item, 'size': stat_info.st_size if not is_directory else 0, 'modified': datetime.fromtimestamp(stat_info.st_mtime).isoformat(), 'is_directory': is_directory, 'permissions': oct(stat_info.st_mode)[-3:], 'path': item_path }) except (OSError, PermissionError): # Skip files we can't access continue # Sort: directories first, then files, both alphabetically files.sort(key=lambda x: (not x['is_directory'], x['name'].lower())) return jsonify({ 'path': path, 'files': files }) except PermissionError: return jsonify({'error': 'Permission denied'}), 403 except Exception as e: logger.error(f"Error browsing path {path}: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/files/download') def api_file_download(): """API endpoint for file downloads""" if 'user' not in session: return jsonify({'error': 'Unauthorized'}), 401 file_path = request.args.get('path') if not file_path: return jsonify({'error': 'No file path provided'}), 400 # Security: Prevent directory traversal if '..' in file_path: return jsonify({'error': 'Invalid file path'}), 400 try: if not os.path.exists(file_path) or not os.path.isfile(file_path): return jsonify({'error': 'File not found'}), 404 return send_file(file_path, as_attachment=True) except PermissionError: return jsonify({'error': 'Permission denied'}), 403 except Exception as e: logger.error(f"Error downloading file: {e}") return jsonify({'error': str(e)}), 500 if __name__ == '__main__': print(f"🚀 Starting SamCloud Dashboard on http://localhost:{config.PORT}") print(f"📁 Static files: {config.STATIC_DIR}") print(f"📄 Templates: {config.TEMPLATES_DIR}") app.run( host=config.HOST, port=config.PORT, debug=config.DEBUG, threaded=True )