300 lines
11 KiB
Python
300 lines
11 KiB
Python
import json
|
|
import logging
|
|
import subprocess
|
|
|
|
import psutil
|
|
from fabric.widgets.box import Box
|
|
from fabric.widgets.button import Button
|
|
from fabric.widgets.circularprogressbar import CircularProgressBar
|
|
from fabric.widgets.label import Label
|
|
from fabric.widgets.revealer import Revealer
|
|
from gi.repository import GLib
|
|
|
|
import config.data as data
|
|
import utils.icons as icons
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class MetricsProvider:
|
|
"""
|
|
Class responsible for obtaining centralized CPU, memory, disk usage, and battery metrics.
|
|
It updates periodically so that all widgets querying it display the same values.
|
|
"""
|
|
def __init__(self):
|
|
self.gpu = []
|
|
self.cpu = 0.0
|
|
self.mem = 0.0
|
|
self.swap = 0.0
|
|
self.disk = []
|
|
|
|
self.bat_percent = 0.0
|
|
self.bat_charging = None
|
|
self.bat_time = 0
|
|
|
|
self._gpu_update_running = False
|
|
|
|
GLib.timeout_add_seconds(1, self._update)
|
|
|
|
def _update(self):
|
|
self.cpu = psutil.cpu_percent(interval=0)
|
|
self.mem = psutil.virtual_memory().percent
|
|
self.swap = psutil.swap_memory().percent
|
|
self.disk = [psutil.disk_usage(path).percent for path in data.METRICS_DISKS]
|
|
|
|
if not self._gpu_update_running:
|
|
self._start_gpu_update_async()
|
|
|
|
return True
|
|
|
|
def _start_gpu_update_async(self):
|
|
"""Starts a new GLib thread to run nvtop in the background."""
|
|
self._gpu_update_running = True
|
|
|
|
GLib.Thread.new("nvtop-thread", lambda _: self._run_nvtop_in_thread(), None)
|
|
|
|
def _run_nvtop_in_thread(self):
|
|
"""Runs nvtop via subprocess in a separate GLib thread."""
|
|
output = None
|
|
error_message = None
|
|
try:
|
|
result = subprocess.check_output(["nvtop", "-s"], text=True, timeout=10)
|
|
output = result
|
|
except FileNotFoundError:
|
|
error_message = "nvtop command not found."
|
|
logger.warning(error_message)
|
|
except subprocess.CalledProcessError as e:
|
|
error_message = f"nvtop failed with exit code {e.returncode}: {e.stderr.strip()}"
|
|
logger.error(error_message)
|
|
except subprocess.TimeoutExpired:
|
|
error_message = "nvtop command timed out."
|
|
logger.error(error_message)
|
|
except Exception as e:
|
|
error_message = f"Unexpected error running nvtop: {e}"
|
|
logger.error(error_message)
|
|
|
|
GLib.idle_add(self._process_gpu_output, output, error_message)
|
|
self._gpu_update_running = False
|
|
|
|
def _process_gpu_output(self, output, error_message):
|
|
"""Process nvtop JSON output on the main loop."""
|
|
try:
|
|
if error_message:
|
|
logger.error(f"GPU update failed: {error_message}")
|
|
self.gpu = []
|
|
elif output:
|
|
try:
|
|
info = json.loads(output)
|
|
if not info or not isinstance(info, list):
|
|
logger.warning("Invalid nvtop data format")
|
|
self.gpu = []
|
|
return False
|
|
|
|
gpu_utils = []
|
|
for gpu in info:
|
|
if not isinstance(gpu, dict):
|
|
continue
|
|
util = gpu.get("gpu_util", "0%")
|
|
if not isinstance(util, str):
|
|
continue
|
|
try:
|
|
# Remove % and convert to int, default to 0 if fails
|
|
util_value = int(util.rstrip("%") or 0)
|
|
gpu_utils.append(util_value)
|
|
except (ValueError, TypeError):
|
|
continue
|
|
|
|
self.gpu = gpu_utils if gpu_utils else []
|
|
except (KeyError, ValueError, TypeError, AttributeError) as e:
|
|
logger.error(f"Failed parsing nvtop JSON: {e}")
|
|
self.gpu = []
|
|
else:
|
|
logger.warning("nvtop returned no output.")
|
|
self.gpu = []
|
|
except json.JSONDecodeError as e:
|
|
logger.error(f"JSON decode error: {e}")
|
|
self.gpu = []
|
|
except Exception as e:
|
|
logger.error(f"Error processing nvtop output: {e}")
|
|
self.gpu = []
|
|
|
|
return False
|
|
|
|
def get_metrics(self):
|
|
return (self.cpu, self.mem, self.swap, self.disk, self.gpu)
|
|
|
|
def get_battery(self):
|
|
return (self.bat_percent, self.bat_charging, self.bat_time)
|
|
|
|
def get_gpu_info(self):
|
|
try:
|
|
result = subprocess.check_output(["nvtop", "-s"], text=True, timeout=5)
|
|
if not result.strip():
|
|
return []
|
|
|
|
info = json.loads(result)
|
|
if not info or not isinstance(info, list):
|
|
return []
|
|
|
|
valid_gpus = []
|
|
for gpu in info:
|
|
if not isinstance(gpu, dict):
|
|
continue
|
|
|
|
gpu_data = {
|
|
'device_name': gpu.get('device_name', 'GPU'),
|
|
'gpu_util': gpu.get('gpu_util', '0%'),
|
|
'mem_util': gpu.get('mem_util', '0%'),
|
|
'temperature': gpu.get('temperature', '0 C'),
|
|
'power': gpu.get('power', '0 W')
|
|
}
|
|
valid_gpus.append(gpu_data)
|
|
|
|
return valid_gpus
|
|
|
|
except FileNotFoundError:
|
|
logger.warning("nvtop not found; GPU info unavailable.")
|
|
return []
|
|
except subprocess.CalledProcessError as e:
|
|
logger.error(f"nvtop init sync failed: {e}")
|
|
return []
|
|
except subprocess.TimeoutExpired:
|
|
logger.error("nvtop init call timed out.")
|
|
return []
|
|
except json.JSONDecodeError as e:
|
|
logger.error(f"Init JSON parse error: {e}")
|
|
return []
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error during GPU init: {e}")
|
|
return []
|
|
|
|
shared_provider = MetricsProvider()
|
|
|
|
class SingularMetric:
|
|
def __init__(self, id, name, icon):
|
|
self.name_markup = name
|
|
self.icon_markup = icon
|
|
|
|
self.icon = Label(name="metrics-icon", markup=icon)
|
|
self.circle = CircularProgressBar(
|
|
name="metrics-circle",
|
|
value=0,
|
|
size=28,
|
|
line_width=2,
|
|
start_angle=150,
|
|
end_angle=390,
|
|
style_classes=id,
|
|
child=self.icon,
|
|
)
|
|
|
|
self.level = Label(name="metrics-level", style_classes=id, label="0%")
|
|
self.revealer = Revealer(
|
|
name=f"metrics-{id}-revealer",
|
|
transition_duration=250,
|
|
transition_type="slide-left",
|
|
child=self.level,
|
|
child_revealed=False,
|
|
)
|
|
|
|
self.box = Box(
|
|
name=f"metrics-{id}-box",
|
|
orientation="h",
|
|
spacing=0,
|
|
children=[self.circle, self.revealer],
|
|
)
|
|
|
|
def markup(self):
|
|
return f"{self.icon_markup} {self.name_markup}: {self.level.get_label()}"
|
|
|
|
class Metrics(Button):
|
|
def __init__(self, **kwargs):
|
|
super().__init__(name="metrics", **kwargs)
|
|
|
|
main_box = Box(
|
|
spacing=0,
|
|
orientation="h" if not data.VERTICAL else "v",
|
|
visible=True,
|
|
all_visible=True,
|
|
)
|
|
|
|
visible = getattr(data, "METRICS_VISIBLE", {'cpu': True, 'ram': True, 'swap': True, 'disk': True, 'gpu': True})
|
|
disks = [SingularMetric("disk", f"DISK ({path})" if len(data.METRICS_DISKS) != 1 else "DISK", icons.disk)
|
|
for path in data.METRICS_DISKS] if visible.get('disk', True) else []
|
|
|
|
gpu_info = shared_provider.get_gpu_info()
|
|
gpus = [SingularMetric(f"gpu", f"GPU ({v['device_name']})" if len(gpu_info) != 1 else "GPU", icons.gpu)
|
|
for v in gpu_info] if visible.get('gpu', True) else []
|
|
|
|
self.cpu = SingularMetric("cpu", "CPU", icons.cpu) if visible.get('cpu', True) else None
|
|
self.ram = SingularMetric("ram", "RAM", icons.memory) if visible.get('ram', True) else None
|
|
self.swap = SingularMetric("swap", "SWAP", icons.swap) if visible.get('swap', True) else None
|
|
self.disk = disks
|
|
self.gpu = gpus
|
|
|
|
for disk in self.disk:
|
|
main_box.add(disk.box)
|
|
main_box.add(Box(name="metrics-sep"))
|
|
if self.ram:
|
|
main_box.add(self.ram.box)
|
|
main_box.add(Box(name="metrics-sep"))
|
|
if self.swap:
|
|
main_box.add(self.swap.box)
|
|
main_box.add(Box(name="metrics-sep"))
|
|
if self.cpu:
|
|
main_box.add(self.cpu.box)
|
|
for gpu in self.gpu:
|
|
main_box.add(Box(name="metrics-sep"))
|
|
main_box.add(gpu.box)
|
|
|
|
self.add(main_box)
|
|
|
|
self.connect("clicked", self.on_click)
|
|
self.labels_visible = False
|
|
|
|
GLib.timeout_add_seconds(1, self.update_metrics)
|
|
|
|
def _format_percentage(self, value: int) -> str:
|
|
"""Formato natural del porcentaje sin forzar ancho fijo."""
|
|
return f"{value}%"
|
|
|
|
def on_click(self, widget):
|
|
if not data.VERTICAL:
|
|
self.labels_visible = not self.labels_visible
|
|
if self.cpu: self.cpu.revealer.set_reveal_child(self.labels_visible)
|
|
if self.ram: self.ram.revealer.set_reveal_child(self.labels_visible)
|
|
if self.swap: self.swap.revealer.set_reveal_child(self.labels_visible)
|
|
for disk in self.disk:
|
|
disk.revealer.set_reveal_child(self.labels_visible)
|
|
for gpu in self.gpu:
|
|
gpu.revealer.set_reveal_child(self.labels_visible)
|
|
|
|
def update_metrics(self):
|
|
cpu, mem, swap, disks, gpus = shared_provider.get_metrics()
|
|
|
|
if self.cpu:
|
|
self.cpu.circle.set_value(cpu / 100.0)
|
|
self.cpu.level.set_label(self._format_percentage(int(cpu)))
|
|
if self.ram:
|
|
self.ram.circle.set_value(mem / 100.0)
|
|
self.ram.level.set_label(self._format_percentage(int(mem)))
|
|
if self.swap:
|
|
self.swap.circle.set_value(swap / 100.0)
|
|
self.swap.level.set_label(self._format_percentage(int(swap)))
|
|
for i, disk in enumerate(self.disk):
|
|
if i < len(disks):
|
|
disk.circle.set_value(disks[i] / 100.0)
|
|
disk.level.set_label(self._format_percentage(int(disks[i])))
|
|
for i, gpu in enumerate(self.gpu):
|
|
if i < len(gpus):
|
|
gpu.circle.set_value(gpus[i] / 100.0)
|
|
gpu.level.set_label(self._format_percentage(int(gpus[i])))
|
|
|
|
tooltip_metrics = []
|
|
if self.disk: tooltip_metrics.extend(self.disk)
|
|
if self.ram: tooltip_metrics.append(self.ram)
|
|
if self.swap: tooltip_metrics.append(self.swap)
|
|
if self.cpu: tooltip_metrics.append(self.cpu)
|
|
if self.gpu: tooltip_metrics.extend(self.gpu)
|
|
self.set_tooltip_markup((" - " if not data.VERTICAL else "\n").join([v.markup() for v in tooltip_metrics]))
|
|
|
|
return True
|