Controller tracks disk bandwidth stats.

This commit is contained in:
Roland 2026-01-22 19:28:54 -07:00
parent c731527883
commit 072fedc656
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
2 changed files with 147 additions and 9 deletions

View file

@ -14,6 +14,18 @@ from typing import Optional
import psutil
# State for bandwidth rate calculations
_previous_network_stats: dict[str, dict] = {}
_previous_disk_stats: dict[str, dict] = {}
def reset_bandwidth_stats():
"""Reset stored bandwidth stats. Useful for testing."""
global _previous_network_stats, _previous_disk_stats
_previous_network_stats = {}
_previous_disk_stats = {}
# CPU frequency paths
MIN_FREQUENCY_PATH = Path('/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_min_freq')
MAX_FREQUENCY_PATH = Path('/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq')
@ -166,31 +178,68 @@ IGNORED_NIC_NAMES = {'lo', 'veth', 'tun', 'docker', 'br-'}
def get_network_status() -> dict:
"""
Get network interface statistics.
Get network interface statistics with per-second bandwidth rates.
Returns format compatible with main API's nic_bandwidth_stats:
dict of interface_name -> stats
"""
global _previous_network_stats
from datetime import datetime
timestamp = datetime.now().timestamp()
counters = dict()
if_stats = psutil.net_if_stats()
for name, counter in sorted(psutil.net_io_counters(pernic=True, nowrap=True).items(), key=lambda i: i[0]):
if name in IGNORED_NIC_NAMES:
# Skip ignored interfaces
if any(name.startswith(ignored) for ignored in IGNORED_NIC_NAMES):
continue
stats = if_stats.get(name)
# Calculate per-second rates using previous values
bytes_recv_ps = 0
bytes_sent_ps = 0
elapsed = 0
if name in _previous_network_stats:
prev = _previous_network_stats[name]
elapsed = timestamp - prev['timestamp']
if elapsed > 0:
bytes_recv_diff = counter.bytes_recv - prev['bytes_recv']
bytes_sent_diff = counter.bytes_sent - prev['bytes_sent']
# Handle counter wrap (be safe, though nowrap=True should prevent this)
if bytes_recv_diff >= 0:
bytes_recv_ps = int(bytes_recv_diff / elapsed)
if bytes_sent_diff >= 0:
bytes_sent_ps = int(bytes_sent_diff / elapsed)
# Store current values for next call
_previous_network_stats[name] = {
'bytes_recv': counter.bytes_recv,
'bytes_sent': counter.bytes_sent,
'timestamp': timestamp,
}
counters[name] = {
'name': name,
'now': timestamp,
'bytes_recv': counter.bytes_recv,
'bytes_sent': counter.bytes_sent,
'speed': int(stats.speed) if stats else 0,
# Initialize bandwidth per second to 0 (requires historical data to calculate)
'bytes_recv_ps': 0,
'bytes_sent_ps': 0,
'bytes_recv_ps': bytes_recv_ps,
'bytes_sent_ps': bytes_sent_ps,
'elapsed': int(elapsed),
}
# Clean up stale entries (interfaces that disappeared)
current_interfaces = set(counters.keys())
stale_interfaces = set(_previous_network_stats.keys()) - current_interfaces
for stale in stale_interfaces:
del _previous_network_stats[stale]
return counters
@ -365,11 +414,13 @@ IGNORED_DISK_NAMES = ('loop', 'ram')
def get_disk_bandwidth_status() -> dict:
"""
Get disk IO bandwidth statistics.
Get disk IO bandwidth statistics with per-second rates.
Returns format compatible with main API's disk_bandwidth_stats:
dict of disk_name -> stats with bytes_read, bytes_write, etc.
"""
global _previous_disk_stats
from datetime import datetime
timestamp = datetime.now().timestamp()
counters = dict()
@ -383,17 +434,51 @@ def get_disk_bandwidth_status() -> dict:
if any(name.startswith(i) for i in IGNORED_DISK_NAMES):
continue
# Calculate per-second rates using previous values
bytes_read_ps = 0
bytes_write_ps = 0
elapsed = 0
if name in _previous_disk_stats:
prev = _previous_disk_stats[name]
elapsed = timestamp - prev['timestamp']
if elapsed > 0:
bytes_read_diff = counter.read_bytes - prev['bytes_read']
bytes_write_diff = counter.write_bytes - prev['bytes_write']
# Handle counter wrap (be safe)
if bytes_read_diff >= 0:
bytes_read_ps = int(bytes_read_diff / elapsed)
if bytes_write_diff >= 0:
bytes_write_ps = int(bytes_write_diff / elapsed)
# Store current values for next call
_previous_disk_stats[name] = {
'bytes_read': counter.read_bytes,
'bytes_write': counter.write_bytes,
'timestamp': timestamp,
}
counters[name] = {
'name': name,
'now': timestamp,
'bytes_read': counter.read_bytes,
'bytes_write': counter.write_bytes,
# Initialize per-second rates to 0 (requires historical data)
'bytes_read_ps': 0,
'bytes_write_ps': 0,
'bytes_read_ps': bytes_read_ps,
'bytes_write_ps': bytes_write_ps,
'max_read_ps': 500_000, # Default max for display scaling
'max_write_ps': 500_000,
'elapsed': int(elapsed),
'speed': 0, # Disk speed not easily available via psutil
}
# Clean up stale entries (disks that disappeared)
current_disks = set(counters.keys())
stale_disks = set(_previous_disk_stats.keys()) - current_disks
for stale in stale_disks:
del _previous_disk_stats[stale]
except Exception:
pass

View file

@ -9,12 +9,14 @@ from unittest import mock
from controller.lib.status import (
get_cpu_status,
get_disk_bandwidth_status,
get_drive_status,
get_load_status,
get_memory_status,
get_network_status,
get_power_status,
get_primary_drive_status,
reset_bandwidth_stats,
)
@ -207,3 +209,54 @@ class TestGetPowerStatus:
result = get_power_status()
assert isinstance(result["under_voltage"], bool)
assert isinstance(result["over_current"], bool)
class TestNetworkBandwidthRates:
"""Tests for network bandwidth per-second rate calculations."""
def test_first_call_returns_zero_rates(self):
"""First call should return zero rates (no previous data)."""
reset_bandwidth_stats()
result = get_network_status()
if result:
iface = list(result.values())[0]
assert iface['bytes_recv_ps'] == 0
assert iface['bytes_sent_ps'] == 0
assert iface['elapsed'] == 0
def test_second_call_has_elapsed_time(self):
"""Second call should have non-zero elapsed time."""
import time
reset_bandwidth_stats()
get_network_status()
time.sleep(1.1) # Wait > 1 second since elapsed is int (truncated)
result = get_network_status()
if result:
iface = list(result.values())[0]
assert iface['elapsed'] >= 1
class TestDiskBandwidthRates:
"""Tests for disk bandwidth per-second rate calculations."""
def test_first_call_returns_zero_rates(self):
"""First call should return zero rates."""
reset_bandwidth_stats()
result = get_disk_bandwidth_status()
if result:
disk = list(result.values())[0]
assert disk['bytes_read_ps'] == 0
assert disk['bytes_write_ps'] == 0
assert disk['elapsed'] == 0
def test_second_call_has_elapsed_time(self):
"""Second call should have non-zero elapsed time."""
import time
reset_bandwidth_stats()
get_disk_bandwidth_status()
time.sleep(1.1) # Wait > 1 second since elapsed is int (truncated)
result = get_disk_bandwidth_status()
if result:
disk = list(result.values())[0]
assert disk['elapsed'] >= 1
assert 'speed' in disk