Merge pull request #26 from rightup/feat/hardware-stats

Feat/hardware stats
This commit is contained in:
Lloyd
2025-11-29 16:02:22 -08:00
committed by GitHub
8 changed files with 412 additions and 178 deletions

View File

@@ -36,6 +36,7 @@ dependencies = [
"cherrypy>=18.0.0",
"paho-mqtt>=1.6.0",
"cherrypy-cors==1.7.0",
"psutil>=5.9.0",
]

View File

@@ -0,0 +1,174 @@
"""
Hardware statistics collection using psutil.
KISS - Keep It Simple Stupid approach.
"""
try:
import psutil
PSUTIL_AVAILABLE = True
except ImportError:
PSUTIL_AVAILABLE = False
psutil = None
import time
import logging
logger = logging.getLogger("HardwareStats")
class HardwareStatsCollector:
def __init__(self):
self.start_time = time.time()
def get_stats(self):
if not PSUTIL_AVAILABLE:
logger.error("psutil not available - cannot collect hardware stats")
return {
"error": "psutil library not available - cannot collect hardware statistics"
}
try:
# Get current timestamp
now = time.time()
uptime = now - self.start_time
# CPU stats
cpu_percent = psutil.cpu_percent(interval=0.1)
cpu_count = psutil.cpu_count()
cpu_freq = psutil.cpu_freq()
# Memory stats
memory = psutil.virtual_memory()
# Disk stats
disk = psutil.disk_usage('/')
# Network stats (total across all interfaces)
net_io = psutil.net_io_counters()
# Load average (Unix only)
load_avg = None
try:
load_avg = psutil.getloadavg()
except (AttributeError, OSError):
# Not available on all systems - use zeros
load_avg = (0.0, 0.0, 0.0)
# System boot time
boot_time = psutil.boot_time()
system_uptime = now - boot_time
# Temperature (if available)
temperatures = {}
try:
temps = psutil.sensors_temperatures()
for name, entries in temps.items():
for i, entry in enumerate(entries):
temp_name = f"{name}_{i}" if len(entries) > 1 else name
temperatures[temp_name] = entry.current
except (AttributeError, OSError):
# Temperature sensors not available
pass
# Format data structure to match Vue component expectations
stats = {
"cpu": {
"usage_percent": cpu_percent,
"count": cpu_count,
"frequency": cpu_freq.current if cpu_freq else 0,
"load_avg": {
"1min": load_avg[0],
"5min": load_avg[1],
"15min": load_avg[2]
}
},
"memory": {
"total": memory.total,
"available": memory.available,
"used": memory.used,
"usage_percent": memory.percent
},
"disk": {
"total": disk.total,
"used": disk.used,
"free": disk.free,
"usage_percent": round((disk.used / disk.total) * 100, 1)
},
"network": {
"bytes_sent": net_io.bytes_sent,
"bytes_recv": net_io.bytes_recv,
"packets_sent": net_io.packets_sent,
"packets_recv": net_io.packets_recv
},
"system": {
"uptime": system_uptime,
"boot_time": boot_time
}
}
# Add temperatures if available
if temperatures:
stats["temperatures"] = temperatures
return stats
except Exception as e:
logger.error(f"Error collecting hardware stats: {e}")
return {
"error": str(e)
}
def get_processes_summary(self, limit=10):
"""
Get top processes by CPU and memory usage.
Returns a dictionary with process information in the format expected by the UI.
"""
if not PSUTIL_AVAILABLE:
logger.error("psutil not available - cannot collect process stats")
return {
"processes": [],
"total_processes": 0,
"error": "psutil library not available - cannot collect process statistics"
}
try:
processes = []
# Get all processes
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent', 'memory_info']):
try:
pinfo = proc.info
# Calculate memory in MB
memory_mb = 0
if pinfo['memory_info']:
memory_mb = pinfo['memory_info'].rss / 1024 / 1024 # RSS in MB
process_data = {
"pid": pinfo['pid'],
"name": pinfo['name'] or 'Unknown',
"cpu_percent": pinfo['cpu_percent'] or 0.0,
"memory_percent": pinfo['memory_percent'] or 0.0,
"memory_mb": round(memory_mb, 1)
}
processes.append(process_data)
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
# Sort by CPU usage and get top processes
top_processes = sorted(processes, key=lambda x: x['cpu_percent'], reverse=True)[:limit]
return {
"processes": top_processes,
"total_processes": len(processes)
}
except Exception as e:
logger.error(f"Error collecting process stats: {e}")
return {
"processes": [],
"total_processes": 0,
"error": str(e)
}

View File

@@ -65,6 +65,11 @@ class StorageCollector:
else:
self.disallowed_packet_types = set()
# Initialize hardware stats collector
from .hardware_stats import HardwareStatsCollector
self.hardware_stats = HardwareStatsCollector()
logger.info("Hardware stats collector initialized")
def _get_live_stats(self) -> dict:
"""Get live stats from RepeaterHandler"""
if not self.repeater_handler:
@@ -228,3 +233,19 @@ class StorageCollector:
def delete_advert(self, advert_id: int) -> bool:
return self.sqlite_handler.delete_advert(advert_id)
def get_hardware_stats(self) -> Optional[dict]:
"""Get current hardware statistics"""
try:
return self.hardware_stats.get_stats()
except Exception as e:
logger.error(f"Error getting hardware stats: {e}")
return None
def get_hardware_processes(self) -> Optional[list]:
"""Get current process summary"""
try:
return self.hardware_stats.get_processes_summary()
except Exception as e:
logger.error(f"Error getting hardware processes: {e}")
return None

View File

@@ -243,6 +243,44 @@ class APIEndpoints:
logger.error(f"Error fetching logs: {e}")
return {"error": str(e), "logs": []}
@cherrypy.expose
@cherrypy.tools.json_out()
def hardware_stats(self):
"""Get comprehensive hardware statistics"""
try:
# Get hardware stats from storage collector
storage = self._get_storage()
if storage:
stats = storage.get_hardware_stats()
if stats:
return self._success(stats)
else:
return self._error("Hardware stats not available (psutil may not be installed)")
else:
return self._error("Storage collector not available")
except Exception as e:
logger.error(f"Error getting hardware stats: {e}")
return self._error(e)
@cherrypy.expose
@cherrypy.tools.json_out()
def hardware_processes(self):
"""Get summary of top processes"""
try:
# Get process stats from storage collector
storage = self._get_storage()
if storage:
processes = storage.get_hardware_processes()
if processes:
return self._success(processes)
else:
return self._error("Process information not available (psutil may not be installed)")
else:
return self._error("Storage collector not available")
except Exception as e:
logger.error(f"Error getting process stats: {e}")
return self._error(e)
@cherrypy.expose
@cherrypy.tools.json_out()
def packet_stats(self, hours=24):

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-r2jdFy7f.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dmg9lJJT.css">
<script type="module" crossorigin src="/assets/index-7Gn2-Mfw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DB3Eq_QU.css">
</head>
<body>
<div id="app"></div>