| """ |
| System information service for gathering platform and application stats. |
| """ |
|
|
| import logging |
| import os |
| import sys |
| from datetime import timedelta |
| from typing import Optional |
|
|
| import psutil |
| from django.conf import settings |
| from django.utils import timezone |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class SystemInfoService: |
| """Service for gathering system and application information.""" |
|
|
| @classmethod |
| def get_version(cls) -> str: |
| """Get PyRunner version.""" |
| try: |
| from pyrunner.version import __version__ |
|
|
| return __version__ |
| except ImportError: |
| return "Unknown" |
|
|
| @classmethod |
| def get_python_version(cls) -> str: |
| """Get Python interpreter version.""" |
| return f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" |
|
|
| @classmethod |
| def get_python_version_full(cls) -> str: |
| """Get full Python version string.""" |
| return sys.version |
|
|
| @classmethod |
| def get_uptime(cls) -> Optional[timedelta]: |
| """ |
| Get application uptime as timedelta. |
| Returns None if start time not captured. |
| """ |
| from core.apps import APP_START_TIME |
|
|
| if APP_START_TIME is None: |
| return None |
| return timezone.now() - APP_START_TIME |
|
|
| @classmethod |
| def get_uptime_display(cls) -> str: |
| """Get human-readable uptime string.""" |
| uptime = cls.get_uptime() |
| if uptime is None: |
| return "Unknown" |
|
|
| total_seconds = int(uptime.total_seconds()) |
| days, remainder = divmod(total_seconds, 86400) |
| hours, remainder = divmod(remainder, 3600) |
| minutes, seconds = divmod(remainder, 60) |
|
|
| parts = [] |
| if days > 0: |
| parts.append(f"{days}d") |
| if hours > 0: |
| parts.append(f"{hours}h") |
| if minutes > 0: |
| parts.append(f"{minutes}m") |
| if not parts or seconds > 0: |
| parts.append(f"{seconds}s") |
|
|
| return " ".join(parts) |
|
|
| @classmethod |
| def get_database_size(cls) -> int: |
| """Get database file size in bytes.""" |
| db_path = settings.DATABASES["default"]["NAME"] |
| try: |
| return os.path.getsize(db_path) |
| except (OSError, FileNotFoundError): |
| return 0 |
|
|
| @classmethod |
| def get_database_size_display(cls) -> str: |
| """Get human-readable database size.""" |
| from core.services.environment_service import EnvironmentService |
|
|
| size = cls.get_database_size() |
| return EnvironmentService.format_disk_usage(size) |
|
|
| @classmethod |
| def get_environments_disk_usage(cls) -> int: |
| """Get total disk usage of all environments in bytes.""" |
| env_root = settings.ENVIRONMENTS_ROOT |
|
|
| if not os.path.isdir(env_root): |
| return 0 |
|
|
| total = 0 |
| try: |
| for dirpath, dirnames, filenames in os.walk(env_root): |
| for f in filenames: |
| fp = os.path.join(dirpath, f) |
| try: |
| total += os.path.getsize(fp) |
| except (OSError, FileNotFoundError): |
| pass |
| except Exception as e: |
| logger.error(f"Failed to calculate environments disk usage: {e}") |
|
|
| return total |
|
|
| @classmethod |
| def get_environments_disk_usage_display(cls) -> str: |
| """Get human-readable environments disk usage.""" |
| from core.services.environment_service import EnvironmentService |
|
|
| size = cls.get_environments_disk_usage() |
| return EnvironmentService.format_disk_usage(size) |
|
|
| @classmethod |
| def get_worker_status(cls) -> dict: |
| """ |
| Get django-q worker status using heartbeat mechanism. |
| |
| Returns dict with: |
| - status: str ("running", "stopped", "unknown") |
| - status_text: str (human-readable status) |
| - configured_workers: int (number of configured workers) |
| - queued_tasks: int (pending tasks in queue) |
| - recent_tasks: int (tasks completed in last hour) |
| - last_task_at: datetime or None |
| - heartbeat_at: datetime or None |
| """ |
| from datetime import timedelta |
|
|
| from django_q.models import OrmQ, Task |
|
|
| from core.models import GlobalSettings |
|
|
| now = timezone.now() |
| one_hour_ago = now - timedelta(hours=1) |
| stale_threshold = now - timedelta(seconds=30) |
| heartbeat_threshold = now - timedelta(seconds=90) |
|
|
| |
| global_settings = GlobalSettings.get_settings() |
| heartbeat_at = global_settings.worker_heartbeat_at |
|
|
| |
| try: |
| queued_count = OrmQ.objects.count() |
| except Exception: |
| queued_count = 0 |
|
|
| |
| try: |
| stale_tasks = OrmQ.objects.filter(lock__lt=stale_threshold).count() |
| except Exception: |
| stale_tasks = 0 |
|
|
| |
| try: |
| recent_tasks = Task.objects.filter(started__gte=one_hour_ago).count() |
| except Exception: |
| recent_tasks = 0 |
|
|
| |
| try: |
| last_task = Task.objects.order_by("-started").first() |
| last_task_at = last_task.started if last_task else None |
| except Exception: |
| last_task_at = None |
|
|
| |
| worker_count = settings.Q_CLUSTER.get("workers", 2) |
|
|
| |
| if stale_tasks > 0: |
| |
| status = "stopped" |
| status_text = "Stopped" |
| elif heartbeat_at and heartbeat_at >= heartbeat_threshold: |
| |
| status = "running" |
| status_text = "Running" |
| elif heartbeat_at and heartbeat_at < heartbeat_threshold: |
| |
| status = "stopped" |
| status_text = "Stopped" |
| else: |
| |
| status = "unknown" |
| status_text = "Unknown" |
|
|
| return { |
| "status": status, |
| "status_text": status_text, |
| "configured_workers": worker_count, |
| "queued_tasks": queued_count, |
| "recent_tasks": recent_tasks, |
| "last_task_at": last_task_at, |
| "heartbeat_at": heartbeat_at, |
| } |
|
|
| @classmethod |
| def get_all_info(cls) -> dict: |
| """ |
| Get all system information in one call. |
| |
| Returns dict with all system info. |
| """ |
| worker_status = cls.get_worker_status() |
| uptime = cls.get_uptime() |
|
|
| return { |
| "version": cls.get_version(), |
| "python_version": cls.get_python_version(), |
| "python_version_full": cls.get_python_version_full(), |
| "uptime": cls.get_uptime_display(), |
| "uptime_seconds": uptime.total_seconds() if uptime else None, |
| "database_size": cls.get_database_size(), |
| "database_size_display": cls.get_database_size_display(), |
| "environments_size": cls.get_environments_disk_usage(), |
| "environments_size_display": cls.get_environments_disk_usage_display(), |
| "worker_status": worker_status, |
| } |
|
|
| @classmethod |
| def _format_bytes(cls, bytes_value: int) -> str: |
| """Format bytes into human-readable string.""" |
| for unit in ["B", "KB", "MB", "GB", "TB"]: |
| if bytes_value < 1024.0: |
| return f"{bytes_value:.1f} {unit}" |
| bytes_value /= 1024.0 |
| return f"{bytes_value:.1f} PB" |
|
|
| @classmethod |
| def get_cpu_usage(cls) -> float: |
| """Get current CPU usage percentage (0-100).""" |
| try: |
| return psutil.cpu_percent(interval=0.1) |
| except Exception as e: |
| logger.error(f"Failed to get CPU usage: {e}") |
| return 0.0 |
|
|
| @classmethod |
| def get_memory_info(cls) -> dict: |
| """ |
| Get RAM usage information. |
| |
| Returns dict with: |
| - total: Total RAM in bytes |
| - used: Used RAM in bytes |
| - available: Available RAM in bytes |
| - percent: Usage percentage (0-100) |
| - total_display: Human-readable total |
| - used_display: Human-readable used |
| """ |
| try: |
| mem = psutil.virtual_memory() |
| return { |
| "total": mem.total, |
| "used": mem.used, |
| "available": mem.available, |
| "percent": mem.percent, |
| "total_display": cls._format_bytes(mem.total), |
| "used_display": cls._format_bytes(mem.used), |
| } |
| except Exception as e: |
| logger.error(f"Failed to get memory info: {e}") |
| return { |
| "total": 0, |
| "used": 0, |
| "available": 0, |
| "percent": 0, |
| "total_display": "Unknown", |
| "used_display": "Unknown", |
| } |
|
|
| @classmethod |
| def get_disk_info(cls) -> dict: |
| """ |
| Get storage/disk usage information. |
| |
| Returns dict with: |
| - total: Total disk space in bytes |
| - used: Used disk space in bytes |
| - free: Free disk space in bytes |
| - percent: Usage percentage (0-100) |
| - total_display: Human-readable total |
| - used_display: Human-readable used |
| """ |
| try: |
| disk = psutil.disk_usage(settings.BASE_DIR) |
| return { |
| "total": disk.total, |
| "used": disk.used, |
| "free": disk.free, |
| "percent": disk.percent, |
| "total_display": cls._format_bytes(disk.total), |
| "used_display": cls._format_bytes(disk.used), |
| } |
| except Exception as e: |
| logger.error(f"Failed to get disk info: {e}") |
| return { |
| "total": 0, |
| "used": 0, |
| "free": 0, |
| "percent": 0, |
| "total_display": "Unknown", |
| "used_display": "Unknown", |
| } |
|
|
| @classmethod |
| def get_system_resources(cls) -> dict: |
| """ |
| Get all system resource metrics in one call. |
| |
| Returns dict with cpu, memory, and disk info. |
| """ |
| return { |
| "cpu": { |
| "percent": cls.get_cpu_usage(), |
| }, |
| "memory": cls.get_memory_info(), |
| "disk": cls.get_disk_info(), |
| } |
|
|