From 0bc40840a4352d7d34eeab61a09df132bdc268e9 Mon Sep 17 00:00:00 2001 From: Xuehai Pan Date: Thu, 16 Mar 2023 20:26:02 +0800 Subject: [PATCH] feat(gui/host): show more metrics (#59) --- CHANGELOG.md | 2 +- docs/source/spelling_wordlist.txt | 1 + nvitop/api/host.py | 7 +++ nvitop/api/utils.py | 29 ++++++++---- nvitop/gui/library/__init__.py | 10 +++- nvitop/gui/library/history.py | 3 +- nvitop/gui/library/process.py | 15 ++++-- nvitop/gui/library/utils.py | 13 +++-- nvitop/gui/screens/main/host.py | 79 +++++++++++++++++++++---------- 9 files changed, 114 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63d8bfa..50e8d6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- +- Show more host metrics (e.g., used virtual memory, uptime) in CLI by [@XuehaiPan](https://github.com/XuehaiPan) in [#59](https://github.com/XuehaiPan/nvitop/pull/59). ### Changed diff --git a/docs/source/spelling_wordlist.txt b/docs/source/spelling_wordlist.txt index ba6dfcb..a331ba4 100644 --- a/docs/source/spelling_wordlist.txt +++ b/docs/source/spelling_wordlist.txt @@ -137,3 +137,4 @@ submodule submodules namespace noqa +uptime diff --git a/nvitop/api/host.py b/nvitop/api/host.py index f80c191..814f698 100644 --- a/nvitop/api/host.py +++ b/nvitop/api/host.py @@ -23,6 +23,7 @@ utilization (CPU, memory, disks, network, sensors) in Python. from __future__ import annotations import os as _os +import time as _time from typing import Callable as _Callable import psutil as _psutil @@ -32,6 +33,7 @@ from psutil import * # noqa: F403 # pylint: disable=wildcard-import,unused-wild __all__ = [name for name in _psutil.__all__ if not name.startswith('_')] + [ 'load_average', + 'uptime', 'memory_percent', 'swap_percent', 'ppid_map', @@ -63,6 +65,11 @@ except AttributeError: return +def uptime() -> float: + """Get the system uptime.""" + return _time.time() - _psutil.boot_time() + + def memory_percent() -> float: """The percentage usage of virtual memory, calculated as ``(total - available) / total * 100``.""" return virtual_memory().percent diff --git a/nvitop/api/utils.py b/nvitop/api/utils.py index cea58dd..403797d 100644 --- a/nvitop/api/utils.py +++ b/nvitop/api/utils.py @@ -497,7 +497,12 @@ SIZE_PATTERN = re.compile( """The regex pattern for human readable size.""" -def bytes2human(b: int | float | NaType) -> str: # pylint: disable=too-many-return-statements +# pylint: disable-next=too-many-return-statements +def bytes2human( + b: int | float | NaType, + *, + min_unit: int = 1, +) -> str: """Convert bytes to a human readable string.""" if b == NA: return NA @@ -508,19 +513,19 @@ def bytes2human(b: int | float | NaType) -> str: # pylint: disable=too-many-ret except ValueError: return NA - if b < KiB: + if b < KiB and min_unit < KiB: return f'{b}B' - if b < MiB: + if b < MiB and min_unit <= KiB: return f'{round(b / KiB)}KiB' - if b <= 20 * GiB: + if b <= 20 * GiB and min_unit <= MiB: return f'{round(b / MiB)}MiB' - if b < 100 * GiB: + if b < 100 * GiB and min_unit <= GiB: return f'{round(b / GiB, 2):.2f}GiB' - if b < 1000 * GiB: + if b < 1000 * GiB and min_unit <= GiB: return f'{round(b / GiB, 1):.1f}GiB' - if b < 100 * TiB: + if b < 100 * TiB and min_unit <= TiB: return f'{round(b / TiB, 2):.2f}TiB' - if b < 1000 * TiB: + if b < 1000 * TiB and min_unit <= TiB: return f'{round(b / TiB, 1):.1f}TiB' if b < 100 * PiB: return f'{round(b / PiB, 2):.2f}PiB' @@ -561,7 +566,11 @@ def human2bytes(s: int | str) -> int: return int(float(size) * SIZE_UNITS[unit]) -def timedelta2human(dt: int | float | datetime.timedelta | NaType) -> str: +def timedelta2human( + dt: int | float | datetime.timedelta | NaType, + *, + round: bool = False, # pylint: disable=redefined-builtin +) -> str: """Convert a number in seconds or a :class:`datetime.timedelta` instance to a human readable string.""" if isinstance(dt, (int, float)): dt = datetime.timedelta(seconds=dt) @@ -569,7 +578,7 @@ def timedelta2human(dt: int | float | datetime.timedelta | NaType) -> str: if not isinstance(dt, datetime.timedelta): return NA - if dt.days >= 4: + if dt.days >= 4 or (round and dt.days >= 1): return f'{dt.days + dt.seconds / 86400:.1f} days' hours, seconds = divmod(86400 * dt.days + dt.seconds, 3600) diff --git a/nvitop/gui/library/__init__.py b/nvitop/gui/library/__init__.py index 18f1d29..1c1c98e 100644 --- a/nvitop/gui/library/__init__.py +++ b/nvitop/gui/library/__init__.py @@ -19,7 +19,15 @@ from nvitop.gui.library.keybinding import ( from nvitop.gui.library.libcurses import libcurses, setlocale_utf8 from nvitop.gui.library.messagebox import MessageBox, send_signal from nvitop.gui.library.mouse import MouseEvent -from nvitop.gui.library.process import GpuProcess, HostProcess, Snapshot, bytes2human, host +from nvitop.gui.library.process import ( + GiB, + GpuProcess, + HostProcess, + Snapshot, + bytes2human, + host, + timedelta2human, +) from nvitop.gui.library.selection import Selection from nvitop.gui.library.utils import ( HOSTNAME, diff --git a/nvitop/gui/library/history.py b/nvitop/gui/library/history.py index 6396ec3..cf38919 100644 --- a/nvitop/gui/library/history.py +++ b/nvitop/gui/library/history.py @@ -97,6 +97,7 @@ class HistoryGraph: # pylint: disable=too-many-instance-attributes ) self.reversed_history = deque([self.baseline - 0.1] * self.maxlen, maxlen=self.maxlen) self._max_value_maintainer = deque([self.baseline - 0.1] * self.maxlen, maxlen=self.maxlen) + self.last_retval = None self.graph = [] self.last_graph = [] @@ -281,7 +282,7 @@ class HistoryGraph: # pylint: disable=too-many-instance-attributes def hook(self, func, get_value=None): @functools.wraps(func) def wrapped(*args, **kwargs): - retval = value = func(*args, **kwargs) + self.last_retval = retval = value = func(*args, **kwargs) if get_value is not None: value = get_value(retval) self.add(value) diff --git a/nvitop/gui/library/process.py b/nvitop/gui/library/process.py index 726ee9e..441e131 100644 --- a/nvitop/gui/library/process.py +++ b/nvitop/gui/library/process.py @@ -4,12 +4,21 @@ # pylint: disable=missing-module-docstring,missing-class-docstring,missing-function-docstring -from nvitop.api import NA +from nvitop.api import NA, GiB from nvitop.api import GpuProcess as GpuProcessBase -from nvitop.api import HostProcess, Snapshot, bytes2human, host, utilization2string +from nvitop.api import HostProcess, Snapshot, bytes2human, host, timedelta2human, utilization2string -__all__ = ['host', 'HostProcess', 'GpuProcess', 'NA', 'Snapshot', 'bytes2human'] +__all__ = [ + 'host', + 'HostProcess', + 'GpuProcess', + 'NA', + 'Snapshot', + 'bytes2human', + 'GiB', + 'timedelta2human', +] class GpuProcess(GpuProcessBase): diff --git a/nvitop/gui/library/utils.py b/nvitop/gui/library/utils.py index 150939b..97bf8e6 100644 --- a/nvitop/gui/library/utils.py +++ b/nvitop/gui/library/utils.py @@ -33,7 +33,7 @@ def cut_string(s, maxlen, padstr='...', align='left'): # pylint: disable=disallowed-name -def make_bar(prefix, percent, width): +def make_bar(prefix, percent, width, *, extra_text=''): bar = f'{prefix}: ' if percent != NA and not (isinstance(percent, float) and not math.isfinite(percent)): if isinstance(percent, str) and percent.endswith('%'): @@ -45,12 +45,15 @@ def make_bar(prefix, percent, width): if remainder > 0: bar += ' ▏▎▍▌▋▊▉'[remainder] if isinstance(percent, float) and len(f'{bar} {percent:.1f}%') <= width: - bar += f' {percent:.1f}%' + text = f'{percent:.1f}%' else: - bar += f' {min(round(percent), 100):d}%'.replace('100%', 'MAX') + text = f'{min(round(percent), 100):d}%'.replace('100%', 'MAX') else: - bar += '░' * (width - len(bar) - 4) + ' N/A' - return bar.ljust(width) + bar += '░' * (width - len(bar) - 4) + text = 'N/A' + if extra_text and len(f'{bar} {text} {extra_text}') <= width: + return f'{bar} {text}'.ljust(width - len(extra_text) - 1) + f' {extra_text}' + return f'{bar} {text}'.ljust(width) try: diff --git a/nvitop/gui/screens/main/host.py b/nvitop/gui/screens/main/host.py index 30f43d9..ceca518 100644 --- a/nvitop/gui/screens/main/host.py +++ b/nvitop/gui/screens/main/host.py @@ -11,9 +11,12 @@ from nvitop.gui.library import ( BufferedHistoryGraph, Device, Displayable, + GiB, + bytes2human, colored, host, make_bar, + timedelta2human, ) @@ -40,8 +43,8 @@ class HostPanel(Displayable): # pylint: disable=too-many-instance-attributes self.cpu_percent = None self.load_average = None - self.memory_percent = None - self.swap_percent = None + self.virtual_memory = None + self.swap_memory = None self._snapshot_daemon = threading.Thread( name='host-snapshot-daemon', target=self._snapshot_target, @@ -91,7 +94,7 @@ class HostPanel(Displayable): # pylint: disable=too-many-instance-attributes dynamic_bound=False, format='CPU: {:.1f}%'.format, )(host.cpu_percent) - host.memory_percent = BufferedHistoryGraph( + host.virtual_memory = BufferedHistoryGraph( interval=1.0, width=77, height=4, @@ -99,9 +102,9 @@ class HostPanel(Displayable): # pylint: disable=too-many-instance-attributes baseline=0.0, upperbound=100.0, dynamic_bound=False, - format='MEM: {:.1f}%'.format, - )(host.memory_percent) - host.swap_percent = BufferedHistoryGraph( + format='{:.1f}%'.format, + )(host.virtual_memory, get_value=lambda vm: vm.percent) + host.swap_memory = BufferedHistoryGraph( interval=1.0, width=77, height=1, @@ -109,8 +112,8 @@ class HostPanel(Displayable): # pylint: disable=too-many-instance-attributes baseline=0.0, upperbound=100.0, dynamic_bound=False, - format='SWP: {:.1f}%'.format, - )(host.swap_percent) + format='{:.1f}%'.format, + )(host.swap_memory, get_value=lambda sm: sm.percent) def percentage(x): return f'{x:.1f}%' if x is not NA else NA @@ -164,13 +167,13 @@ class HostPanel(Displayable): # pylint: disable=too-many-instance-attributes def take_snapshots(self): host.cpu_percent() - host.memory_percent() - host.swap_percent() + host.virtual_memory() + host.swap_memory() self.load_average = host.load_average() self.cpu_percent = host.cpu_percent.history.last_value - self.memory_percent = host.memory_percent.history.last_value - self.swap_percent = host.swap_percent.history.last_value + self.virtual_memory = host.virtual_memory.history.last_retval + self.swap_memory = host.swap_memory.history.last_retval total_memory_used = 0 total_memory_total = 0 @@ -255,9 +258,23 @@ class HostPanel(Displayable): # pylint: disable=too-many-instance-attributes if self.compact: width_right = len(load_average) + 4 width_left = self.width - 2 - width_right - cpu_bar = '[ {} ]'.format(make_bar('CPU', self.cpu_percent, width_left - 4)) - memory_bar = '[ {} ]'.format(make_bar('MEM', self.memory_percent, width_left - 4)) - swap_bar = '[ {} ]'.format(make_bar('SWP', self.swap_percent, width_right - 4)) + cpu_bar = '[ {} ]'.format( + make_bar( + 'CPU', + self.cpu_percent, + width_left - 4, + extra_text=f' UPTIME: {timedelta2human(host.uptime(), round=True)}', + ), + ) + memory_bar = '[ {} ]'.format( + make_bar( + 'MEM', + self.virtual_memory.percent, + width_left - 4, + extra_text=f' USED: {bytes2human(self.virtual_memory.used, min_unit=GiB)}', + ), + ) + swap_bar = '[ {} ]'.format(make_bar('SWP', self.swap_memory.percent, width_right - 4)) self.addstr(self.y, self.x, f'{cpu_bar} ( {load_average} )') self.addstr(self.y + 1, self.x, f'{memory_bar} {swap_bar}') self.color_at(self.y, self.x, width=len(cpu_bar), fg='cyan', attr='bold') @@ -305,11 +322,11 @@ class HostPanel(Displayable): # pylint: disable=too-many-instance-attributes self.addstr(y, self.x + 1, line) self.color(fg='magenta') - for y, line in enumerate(host.memory_percent.history.graph, start=self.y + 6): + for y, line in enumerate(host.virtual_memory.history.graph, start=self.y + 6): self.addstr(y, self.x + 1, line) self.color(fg='blue') - for y, line in enumerate(host.swap_percent.history.graph, start=self.y + 10): + for y, line in enumerate(host.swap_memory.history.graph, start=self.y + 10): self.addstr(y, self.x + 1, line) if self.width >= 100: @@ -342,12 +359,12 @@ class HostPanel(Displayable): # pylint: disable=too-many-instance-attributes self.addstr( self.y + 9, self.x + 1, - f' {host.memory_percent.history} ', + f' MEM: {bytes2human(self.virtual_memory.used, min_unit=GiB)} ({host.virtual_memory.history}) ', ) self.addstr( self.y + 10, self.x + 1, - f' {host.swap_percent.history} ', + f' SWP: {bytes2human(self.swap_memory.used, min_unit=GiB)} ({host.swap_memory.history}) ', ) if self.width >= 100: self.addstr(self.y, self.x + 79, f' {gpu_memory_percent} ') @@ -364,8 +381,8 @@ class HostPanel(Displayable): # pylint: disable=too-many-instance-attributes def print(self): self.cpu_percent = host.cpu_percent() - self.memory_percent = host.memory_percent() - self.swap_percent = host.swap_percent() + self.virtual_memory = host.virtual_memory() + self.swap_memory = host.swap_memory() self.load_average = host.load_average() if self.load_average is not None: @@ -378,9 +395,23 @@ class HostPanel(Displayable): # pylint: disable=too-many-instance-attributes width_right = len(load_average) + 4 width_left = self.width - 2 - width_right - cpu_bar = '[ {} ]'.format(make_bar('CPU', self.cpu_percent, width_left - 4)) - memory_bar = '[ {} ]'.format(make_bar('MEM', self.memory_percent, width_left - 4)) - swap_bar = '[ {} ]'.format(make_bar('SWP', self.swap_percent, width_right - 4)) + cpu_bar = '[ {} ]'.format( + make_bar( + 'CPU', + self.cpu_percent, + width_left - 4, + extra_text=f' UPTIME: {timedelta2human(host.uptime(), round=True)}', + ), + ) + memory_bar = '[ {} ]'.format( + make_bar( + 'MEM', + self.virtual_memory.percent, + width_left - 4, + extra_text=f' USED: {bytes2human(self.virtual_memory.used, min_unit=GiB)}', + ), + ) + swap_bar = '[ {} ]'.format(make_bar('SWP', self.swap_memory.percent, width_right - 4)) lines = [ '{} {}'.format(