wrolpi/wrolpi/status.py
2025-07-20 16:26:45 -06:00

647 lines
21 KiB
Python
Executable file

#!/usr/bin/env python3
import asyncio
import json
import multiprocessing
import os
import pathlib
import re
import statistics
import subprocess
from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path
from typing import List, Optional, Dict
import jc
from wrolpi.api_utils import api_app, perpetual_signal
from wrolpi.cmd import which, run_command
from wrolpi.common import logger, get_warn_once, unique_by_predicate, partition, TRACE_LEVEL
from wrolpi.dates import now
from wrolpi.vars import PYTEST
try:
import psutil
except ImportError:
logger.warning('Unable to import psutil!')
psutil = None
logger = logger.getChild(__name__)
UPTIME_BIN = which('uptime', '/usr/bin/uptime')
warn_once = get_warn_once('Unable to use psutil', logger)
warn_cpu_once = get_warn_once('Cannot read CPU stats', logger)
warn_cpu_frequency_once = get_warn_once('Cannot read CPU frequency paths', logger)
warn_cpu_temperature_once = get_warn_once('Cannot read CPU Temperature stats', logger)
warn_iostat_once = get_warn_once('Cannot get iostat stats', logger)
status_worker_warn_once = get_warn_once('Status worker encountered error', logger)
@dataclass
class SystemLoad:
minute_1: Decimal = 0
minute_5: Decimal = 0
minute_15: Decimal = 0
def __json__(self) -> dict:
return dict(
minute_1=str(round(self.minute_1, 2)),
minute_5=str(round(self.minute_5, 2)),
minute_15=str(round(self.minute_15, 2)),
)
LOAD_REGEX = re.compile(r'.+?load average: (.+?), (.+?), (.*)')
LOAD_AVG_FILE = pathlib.Path('/proc/loadavg')
async def get_load_stats() -> SystemLoad:
"""Read loadavg file and return the 1, 5, and 15 minute system loads."""
if LOAD_AVG_FILE.exists():
# Read load information from proc file.
# Example: 0.99 1.06 1.02 1/2305 43879
loadavg = LOAD_AVG_FILE.read_text()
load_1, load_5, load_15, *_ = loadavg.split(' ')
else:
load_1, load_5, load_15 = os.getloadavg()
load = SystemLoad(Decimal(load_1), Decimal(load_5), Decimal(load_15))
return load
@dataclass
class IostatInfo:
percent_idle: float = None
percent_iowait: float = None
percent_nice: float = None
percent_steal: float = None
percent_system: float = None
percent_user: float = None
def __json__(self) -> dict:
return dict(
percent_idle=self.percent_idle,
percent_iowait=self.percent_iowait,
percent_nice=self.percent_nice,
percent_steal=self.percent_steal,
percent_system=self.percent_system,
percent_user=self.percent_user,
)
async def get_iostat_stats() -> IostatInfo:
try:
result = await run_command(
('iostat', '3', '2'), # 3 second interval to gather more data, get 2 count.
log_command=False,
)
if result.stdout and result.return_code == 0:
iostat_stats = jc.parse('iostat', result.stdout.decode(), quiet=True)
if iostat_stats:
cpu_statuses, _ = partition(lambda i: i['type'] == 'cpu', iostat_stats)
_, cpu_stats = cpu_statuses # Use the second status because the first is a lie.
if logger.isEnabledFor(TRACE_LEVEL):
logger.trace(
f'get_iostat_stats'
f' percent_iowait={cpu_stats["percent_iowait"]}'
f' percent_idle={cpu_stats["percent_idle"]}')
# Return only the CPU stats.
return IostatInfo(
percent_idle=cpu_stats['percent_idle'],
percent_iowait=cpu_stats['percent_iowait'],
percent_nice=cpu_stats['percent_nice'],
percent_steal=cpu_stats['percent_steal'],
percent_system=cpu_stats['percent_system'],
percent_user=cpu_stats['percent_user'],
)
except Exception as e:
if logger.isEnabledFor(TRACE_LEVEL):
logger.trace(f'get_iostat_stats failed: {e}')
warn_iostat_once(e)
return IostatInfo()
@dataclass
class ProcessInfo:
pid: int = None
percent_cpu: int = None
percent_mem: int = None
command: str = None
def __json__(self) -> dict:
return dict(
pid=self.pid,
percent_cpu=self.percent_cpu,
percent_mem=self.percent_mem,
command=self.command,
)
@dataclass
class CPUInfo:
cores: int = None
cur_frequency: int = None
max_frequency: int = None
min_frequency: int = None
percent: int = None
temperature: Optional[int] = None
high_temperature: Optional[float] = None
critical_temperature: Optional[float] = None
def __json__(self) -> dict:
return dict(
cores=self.cores,
cur_frequency=self.cur_frequency,
max_frequency=self.max_frequency,
min_frequency=self.min_frequency,
percent=self.percent,
temperature=self.temperature,
high_temperature=self.high_temperature,
critical_temperature=self.critical_temperature,
)
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')
CUR_FREQUENCY_PATH = Path('/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq')
TEMPERATURE_PATH = Path('/sys/class/thermal/thermal_zone0/temp')
TOP_REGEX = re.compile(r'^%Cpu\(s\):\s+(\d+\.\d+)', re.MULTILINE)
CORE_COUNT = multiprocessing.cpu_count()
async def get_cpu_stats() -> CPUInfo:
"""Get core count, max freq, min freq, current freq, cpu temperature."""
percent = int(psutil.cpu_percent())
min_frequency = max_frequency = cur_frequency = None
try:
min_frequency = int(MIN_FREQUENCY_PATH.read_text())
max_frequency = int(MAX_FREQUENCY_PATH.read_text())
cur_frequency = int(CUR_FREQUENCY_PATH.read_text())
except Exception as e:
warn_cpu_frequency_once(e)
# Get temperature using psutil.
temperature = high_temperature = critical_temperature = None
try:
temp = psutil.sensors_temperatures()
name = 'coretemp' if 'coretemp' in temp else list(temp.keys())[0]
# Temperatures may be None. Get average temperatures from CPU because one core may be hot.
temperatures = temp.get(name)
temperature = temperature or statistics.median([i.current or 0 for i in temperatures])
high_temperature = statistics.median([i.high or 0 for i in temperatures])
critical_temperature = statistics.median([i.critical or 0 for i in temperatures])
# Temperatures may not exist.
high_temperature = high_temperature or 60
critical_temperature = critical_temperature or 95
if high_temperature and high_temperature == critical_temperature:
# Display yellow warning before red warning.
high_temperature = critical_temperature - 25
except Exception as e:
warn_cpu_temperature_once(e)
info = CPUInfo(
cores=CORE_COUNT,
critical_temperature=int(critical_temperature) if critical_temperature else None,
# Current frequency is between minimum and maximum.
cur_frequency=cur_frequency - min_frequency if cur_frequency else None,
high_temperature=int(high_temperature) if high_temperature else None,
max_frequency=max_frequency,
min_frequency=min_frequency,
percent=percent,
temperature=int(temperature) if temperature else None,
)
return info
PS_CMD = ('ps', 'aux', '--sort=-%cpu', '--cols=512')
IGNORED_PROCESS_COMMANDS = {' '.join(PS_CMD), '<defunct>'}
async def get_processes_stats() -> List[ProcessInfo]:
result = await run_command(PS_CMD, log_command=False)
# Do not include `ps` in the top processes.
ps_pid = result.pid
ps_info = jc.parse('ps', result.stdout.decode(), quiet=True)
processes = sorted(ps_info, key=lambda i: i['cpu_percent'], reverse=True)
processes = [i for i in processes if i['pid'] != ps_pid
and i['cpu_percent'] > 1
and not any(j in i['command'] for j in IGNORED_PROCESS_COMMANDS)]
processes = unique_by_predicate(processes, lambda i: i['command'][:30])
processes = processes[:10]
processes_info = [ProcessInfo(
pid=int(i['pid']),
percent_cpu=int(i['cpu_percent']),
percent_mem=int(i['mem_percent']),
command=i['command'],
) for i in processes]
return processes_info
MEMORY_TOTAL = re.compile(r'^MemTotal:\s+(\d+)\s+kB$', re.MULTILINE)
MEMORY_FREE = re.compile(r'^MemFree:\s+(\d+)\s+kB$', re.MULTILINE)
MEMORY_CACHED = re.compile(r'^Cached:\s+(\d+)\s+kB$', re.MULTILINE)
@dataclass
class MemoryStats:
total: int = None
used: int = None
free: int = None
cached: int = None
def __json__(self) -> dict:
return dict(
total=self.total,
used=self.used,
free=self.free,
cached=self.cached,
)
def get_memory_stats_psutil():
mem = psutil.virtual_memory()
mem = MemoryStats(
total=mem.total,
used=mem.used,
free=mem.free,
cached=mem.cached,
)
return mem
async def get_memory_stats():
try:
return get_memory_stats_psutil()
except Exception as e:
warn_once(e)
meminfo = pathlib.Path('/proc/meminfo')
if meminfo.is_file() and (contents := meminfo.read_text()):
total = int(i[0]) if (i := MEMORY_TOTAL.findall(contents)) else None
free = int(i[0]) if (i := MEMORY_FREE.findall(contents)) else None
cached = int(i[0]) if (i := MEMORY_CACHED.findall(contents)) else None
mem = MemoryStats(
total=int(total * 1024) if total else None,
free=int(free * 1024) if free else None,
cached=int(cached * 1024) if cached else None,
used=int(total - free) if total and free else None,
)
return mem
return MemoryStats()
@dataclass
class DriveInfo:
mount: str = None
percent: int = 0
size: int = 0
used: int = 0
def __json__(self) -> dict:
return dict(
mount=self.mount,
percent=self.percent,
size=self.size,
used=self.used,
)
DRIVE_REGEX = re.compile(r'^.+?' # filesystem
r'\s+(\d+)K' # size
r'\s+(\d+)K' # used
r'\s+(\d+)K' # available
r'\s+(\d+)%' # use %
r'\s+(.*)', # mount
re.MULTILINE)
IGNORED_DRIVES = ['/boot', '/etc']
VALID_FORMATS = {'btrfs', 'ext4', 'ext3', 'ext2', 'vfat', 'exfat', 'apfs'}
def get_drives_info_psutil() -> List[DriveInfo]:
info = {}
disks = psutil.disk_partitions()
for disk in disks:
if any(disk.mountpoint.startswith(i) for i in IGNORED_DRIVES):
continue
if disk.fstype not in VALID_FORMATS:
continue
if disk.device not in info:
# Only use the first partition.
usage = psutil.disk_usage(disk.mountpoint)
info[disk.device] = DriveInfo(
mount=disk.mountpoint,
percent=int(usage.percent),
size=int(usage.total),
used=int(usage.used),
)
info = sorted(info.values(), key=lambda i: i.mount)
return info
async def get_drives_stats() -> List[DriveInfo]:
try:
return get_drives_info_psutil()
except Exception as e:
warn_once(e)
# Fallback to using `df` to fetch disk information.
proc = await asyncio.subprocess.create_subprocess_shell(
'df --type btrfs --type ext4 --type ext3 --type ext2 --type vfat -Bk',
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
drives = []
for size, used, _, percent, mount in DRIVE_REGEX.findall(stdout.decode()):
mount = mount.strip()
if any(mount.startswith(i) for i in IGNORED_DRIVES):
continue
size = int(size) * 1024
used = int(used) * 1024
drives.append(DriveInfo(
mount=mount,
percent=int(percent),
size=size,
used=used,
))
drives = sorted(drives, key=lambda i: i.mount)
return drives
@dataclass
class NICBandwidthInfo:
bytes_recv: int = None
bytes_sent: int = None
elapsed: int = None
name: str = None
speed: int = None
now: float = None
def __json__(self) -> dict:
return dict(
bytes_recv=self.bytes_recv,
bytes_sent=self.bytes_sent,
elapsed=self.elapsed,
name=self.name,
speed=self.speed,
)
IGNORED_NIC_NAMES = {
'lo',
'veth',
'tun',
'docker',
'br-',
}
@dataclass
class DiskBandwidthInfo:
bytes_recv: int = None
bytes_sent: int = None
bytes_read_ps: int = None
bytes_write_ps: int = None
elapsed: int = None
name: str = None
now: float = None
max_read_ps: int = None
max_write_ps: int = None
def __json__(self) -> dict:
return dict(
bytes_recv=self.bytes_recv,
bytes_sent=self.bytes_sent,
bytes_read_ps=self.bytes_read_ps,
bytes_write_ps=self.bytes_write_ps,
elapsed=self.elapsed,
name=self.name,
now=self.now,
max_read_ps=self.max_read_ps,
max_write_ps=self.max_write_ps,
)
IGNORED_DISK_NAMES = (
'loop',
'ram',
)
def _calculate_bytes_per_second(old_stats: dict, new_stats: dict) -> Dict[str, int]:
"""Calculate the bytes-per-second between the oldest and newest counter."""
try:
# NICs
old_now, old_recv, old_sent = old_stats['now'], old_stats['bytes_recv'], old_stats['bytes_sent']
new_now, new_recv, new_sent = new_stats['now'], new_stats['bytes_recv'], new_stats['bytes_sent']
except KeyError:
# Disks
old_now, old_recv, old_sent = old_stats['now'], old_stats['bytes_read'], old_stats['bytes_write']
new_now, new_recv, new_sent = new_stats['now'], new_stats['bytes_read'], new_stats['bytes_write']
elapsed = int(new_now - old_now)
if elapsed == 0:
return dict(bytes_recv_ps=0, bytes_sent_ps=0, elapsed=0)
bytes_recv_ps = int((new_recv - old_recv) // elapsed)
bytes_sent_ps = int((new_sent - old_sent) // elapsed)
return dict(bytes_recv_ps=bytes_recv_ps, bytes_sent_ps=bytes_sent_ps, elapsed=elapsed)
def _get_nic_counters() -> dict:
"""
Get the instant network statistics for the provided NIC.
"""
timestamp = 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:
continue
stats = if_stats[name]
counters[name] = {
'name': name,
'now': timestamp,
'bytes_recv': counter.bytes_recv,
'bytes_sent': counter.bytes_sent,
'speed': int(stats.speed),
}
return counters
async def get_nic_counters(shared_status):
new_nic_stats = _get_nic_counters()
old_nic_stats = shared_status['nic_bandwidth_stats']
nic_bandwidth_stats = dict()
for name, old_stats in old_nic_stats.items():
new_stats = new_nic_stats[name]
new_stats.update(_calculate_bytes_per_second(old_stats, new_stats))
nic_bandwidth_stats[name] = new_stats
return nic_bandwidth_stats
def _get_disk_counters() -> dict:
"""
Get the instant disk statistics for the all disks.
"""
timestamp = now().timestamp()
counters = dict()
for name, counter in sorted(psutil.disk_io_counters(perdisk=True).items(), key=lambda i: i[0]):
if any(name.startswith(i) for i in counters):
# Report only the first disk. Do not report its partitions. Disks are sorted to make this work!
# i.e. report "sda" but not "sda1" or "sda2".
continue
if any(name.startswith(i) for i in IGNORED_DISK_NAMES):
continue
counters[name] = {
'name': name,
'now': timestamp,
'bytes_read': counter.read_bytes,
'bytes_write': counter.write_bytes,
}
return counters
async def get_disk_counters(shared_status):
"""Get stats about bandwidth per disk."""
new_disk_stats = _get_disk_counters()
old_disk_stats = shared_status['disk_bandwidth_stats']
disk_bandwidth_stats = dict()
for name, old_stats in old_disk_stats.items():
new_stats = new_disk_stats[name]
bps = _calculate_bytes_per_second(old_stats, new_stats)
new_stats.update(dict(
elapsed=bps['elapsed'],
bytes_read_ps=bps['bytes_recv_ps'],
bytes_write_ps=bps['bytes_sent_ps'],
max_read_ps=max(bps['bytes_recv_ps'], old_stats['max_read_ps']),
max_write_ps=max(bps['bytes_sent_ps'], old_stats['max_write_ps']),
))
disk_bandwidth_stats[name] = new_stats
return disk_bandwidth_stats
@dataclass
class PowerStats:
under_voltage: bool = False
over_current: bool = False
def __json__(self) -> dict:
return dict(
under_voltage=self.under_voltage,
over_current=self.over_current,
)
async def get_power_stats() -> PowerStats:
logger.trace(f'get_power_stats called')
try:
cmd = ('dmesg',)
result = await run_command(cmd, log_command=False)
if result.stdout and result.return_code == 0:
under_voltage = b' Undervoltage detected' in result.stdout
over_current = b' over-current change ' in result.stdout
return PowerStats(
under_voltage=under_voltage,
over_current=over_current,
)
except Exception as e:
warn_iostat_once(e)
return PowerStats()
DISABLE_STATUS_WORKER = bool(PYTEST) # Do not run status worker while testing, unless explicitly needed.
@perpetual_signal(sleep=5)
async def status_worker(count: int = None, sleep_time: int = 5):
"""A background process which will gather historical data about system statistics."""
shared_status = api_app.shared_ctx.status
if PYTEST and DISABLE_STATUS_WORKER:
# Tests are running, status worker has not been enabled by `start_status_worker` fixture.
return
if count is not None and count <= 0:
logger.debug('Status worker ran out of count')
return dict(**shared_status)
load_stats = None
try:
# Update global `status` dict with stats that are gathered instantly.
cpu_stats, load_stats, drives_stats, memory_stats, processes_stats, iostat_stats, power_stats = \
await asyncio.gather(
asyncio.create_task(get_cpu_stats(), name='get_cpu_stats'),
asyncio.create_task(get_load_stats(), name='get_load_stats'),
asyncio.create_task(get_drives_stats(), name='get_drives_stats'),
asyncio.create_task(get_memory_stats(), name='get_memory_stats'),
asyncio.create_task(get_processes_stats(), name='get_processes_stats'),
asyncio.create_task(get_iostat_stats(), name='get_iostat_stats'),
asyncio.create_task(get_power_stats(), name='get_power_stats'),
)
shared_status.update({
'cpu_stats': cpu_stats.__json__(),
'load_stats': load_stats.__json__(),
'drives_stats': [i.__json__() for i in drives_stats],
'processes_stats': [i.__json__() for i in processes_stats],
'memory_stats': memory_stats.__json__(),
'last_status': now().isoformat(),
'iostat_stats': iostat_stats.__json__(),
'power_stats': power_stats.__json__(),
})
if 'disk_bandwidth_stats' not in shared_status:
# Worker has just been started, fill it with some starting bandwidth data.
disk_info = _get_disk_counters()
for name in disk_info.keys():
disk_info[name].update({'max_read_ps': 500_000, 'max_write_ps': 500_000})
shared_status.update({
'nic_bandwidth_stats': _get_nic_counters(),
'disk_bandwidth_stats': disk_info,
})
# Break out and call the `finally` code below.
return
# Calculate the difference between the previous status worker's bandwidth count and now.
shared_status.update({
'nic_bandwidth_stats': await get_nic_counters(shared_status),
'disk_bandwidth_stats': await get_disk_counters(shared_status),
})
except Exception as e:
status_worker_warn_once(e)
raise
finally:
# Always sleep so this does not become a busy loop with errors.
if load_stats and load_stats.minute_1 and load_stats.minute_1 > multiprocessing.cpu_count():
# System is stressed, slow down status updates.
await asyncio.sleep((sleep_time * float(load_stats.minute_1)) / multiprocessing.cpu_count())
if count:
# Running in testing, or __main__.
return await status_worker(count - 1, sleep_time)
if __name__ == '__main__':
from wrolpi.contexts import attach_shared_contexts
loop = asyncio.get_event_loop()
attach_shared_contexts(api_app)
loop.run_until_complete(status_worker(2, sleep_time=2))
print(json.dumps(dict(api_app.shared_ctx.status), indent=2))