Allow 'textual run', improve internals
Allow 'textual run', improve internals
This commit is contained in:
commit
654d84014d
5 changed files with 112 additions and 63 deletions
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "amdgpu-stats"
|
name = "amdgpu-stats"
|
||||||
version = "0.1.14"
|
version = "0.1.15"
|
||||||
description = "A module/TUI for AMD GPU statistics"
|
description = "A module/TUI for AMD GPU statistics"
|
||||||
authors = ["Josh Lay <pypi@jlay.io>"]
|
authors = ["Josh Lay <pypi@jlay.io>"]
|
||||||
repository = "https://github.com/joshlay/amdgpu_stats"
|
repository = "https://github.com/joshlay/amdgpu_stats"
|
||||||
|
@ -14,7 +14,7 @@ textual = ">=0.16.0"
|
||||||
humanfriendly = ">=10.0"
|
humanfriendly = ">=10.0"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
amdgpu-stats = "amdgpu_stats.tui:start"
|
amdgpu-stats = "amdgpu_stats:textual_run"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core"]
|
requires = ["poetry-core"]
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""__init__.py for amdgpu-stats"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from .tui import app
|
||||||
|
from .utils import (
|
||||||
|
AMDGPU_CARDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_for_cards() -> bool:
|
||||||
|
"""Used by '__main__' and 'textual_run', they should exit w/ a message if no cards
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: If any AMD cards found or not"""
|
||||||
|
if len(AMDGPU_CARDS) > 0:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def textual_run() -> None:
|
||||||
|
"""runs the AMD GPU Stats TUI; called only when in an interactive shell"""
|
||||||
|
if check_for_cards():
|
||||||
|
amdgpu_stats_app = app(watch_css=True)
|
||||||
|
amdgpu_stats_app.run()
|
||||||
|
else:
|
||||||
|
sys.exit('Could not find an AMD GPU, exiting.')
|
|
@ -1,13 +1,12 @@
|
||||||
"""TUI for amdgpu_stats
|
"""TUI for amdgpu_stats
|
||||||
|
|
||||||
This file aims to ensure the TUI only starts in interactive shells"""
|
This file aims to ensure the TUI only starts in interactive shells
|
||||||
from .tui import start
|
|
||||||
|
|
||||||
|
import/use 'amdgpu_stats.utils' to access functions for metrics"""
|
||||||
|
|
||||||
def main():
|
from . import textual_run
|
||||||
"""main function, spawns the TUI for amdgpu_stats"""
|
|
||||||
start()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
textual_run()
|
||||||
|
else:
|
||||||
|
pass
|
||||||
|
|
|
@ -40,3 +40,23 @@ Header {
|
||||||
content-align: center middle;
|
content-align: center middle;
|
||||||
background: $panel;
|
background: $panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Footer {
|
||||||
|
background: #073b61;
|
||||||
|
dock: bottom;
|
||||||
|
height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer--highlight {
|
||||||
|
background: #81a1c1;
|
||||||
|
color: #434c5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer--highlight-key {
|
||||||
|
background: #88c0d0;
|
||||||
|
color: #434c5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer--key {
|
||||||
|
background: #004578;
|
||||||
|
}
|
||||||
|
|
|
@ -14,8 +14,8 @@ Functions:
|
||||||
"""
|
"""
|
||||||
# disable superfluouos linting
|
# disable superfluouos linting
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
|
@ -61,39 +61,62 @@ class Notification(Static):
|
||||||
class GPUStatsWidget(Static):
|
class GPUStatsWidget(Static):
|
||||||
"""The main stats widget."""
|
"""The main stats widget."""
|
||||||
|
|
||||||
# define the columns for the stats table; used as keys during update
|
def get_column_data_mapping(self, card: Optional[str] = None) -> dict:
|
||||||
columns = ["Card",
|
'''Returns a dictionary of stats
|
||||||
"Core clock",
|
|
||||||
"Memory clock",
|
Columns are derived from keys, and values provide measurements
|
||||||
"Utilization",
|
*Measurements require `card`*'''
|
||||||
"Voltage",
|
if card is None:
|
||||||
"Power",
|
return {
|
||||||
"[italic]Limit",
|
"Card": "",
|
||||||
"[italic]Default",
|
"Core clock": "",
|
||||||
"[italic]Capability",
|
"Memory clock": "",
|
||||||
"Fan RPM",
|
"Utilization": "",
|
||||||
"Edge temp",
|
"Voltage": "",
|
||||||
"Junction temp",
|
"Power": "",
|
||||||
"Memory temp"]
|
"[italic]Limit": "",
|
||||||
# initialize empty/default instance vars
|
"[italic]Default": "",
|
||||||
|
"[italic]Capability": "",
|
||||||
|
"Fan RPM": "",
|
||||||
|
"Edge temp": "",
|
||||||
|
"Junction temp": "",
|
||||||
|
"Memory temp": ""
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"Card": card,
|
||||||
|
"Core clock": get_clock('core', card=card, format_freq=True),
|
||||||
|
"Memory clock": get_clock('memory', card=card, format_freq=True),
|
||||||
|
"Utilization": f'{get_gpu_usage(card=card)}%',
|
||||||
|
"Voltage": f'{get_voltage(card=card)}V',
|
||||||
|
"Power": f'{get_power_stats(card=card)["average"]}W',
|
||||||
|
"[italic]Limit": f'{get_power_stats(card=card)["limit"]}W',
|
||||||
|
"[italic]Default": f'{get_power_stats(card=card)["default"]}W',
|
||||||
|
"[italic]Capability": f'{get_power_stats(card=card)["capability"]}W',
|
||||||
|
"Fan RPM": f'{get_fan_rpm(card=card)}',
|
||||||
|
"Edge temp": f"{get_temp_stat(name='edge', card=card)}C",
|
||||||
|
"Junction temp": f"{get_temp_stat(name='junction', card=card)}C",
|
||||||
|
"Memory temp": f"{get_temp_stat(name='mem', card=card)}C"
|
||||||
|
}
|
||||||
|
|
||||||
|
# initialize empty/default instance vars and objects
|
||||||
data = {}
|
data = {}
|
||||||
stats_table = None
|
stats_table = None
|
||||||
text_log = None
|
|
||||||
tabbed_container = None
|
tabbed_container = None
|
||||||
table = None
|
text_log = None
|
||||||
|
timer_stats = None
|
||||||
# mark the table as needing initialization (with rows)
|
# mark the table as needing initialization (with rows)
|
||||||
table_needs_init = True
|
table_needs_init = True
|
||||||
timer_stats = None
|
|
||||||
|
|
||||||
def __init__(self, *args, cards=None, **kwargs):
|
def __init__(self, *args, cards=None, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
self.cards = cards
|
self.cards = cards
|
||||||
self.text_log = TextLog(highlight=True,
|
self.text_log = TextLog(highlight=True,
|
||||||
markup=True,
|
markup=True,
|
||||||
name='log_gpu',
|
name='log_gpu',
|
||||||
classes='logs')
|
classes='logs')
|
||||||
self.stats_table = DataTable(zebra_stripes=True,
|
self.stats_table = DataTable(zebra_stripes=True,
|
||||||
show_cursor=False,
|
show_cursor=True,
|
||||||
name='stats_table',
|
name='stats_table',
|
||||||
classes='stat_table')
|
classes='stat_table')
|
||||||
self.tabbed_container = TabbedContent()
|
self.tabbed_container = TabbedContent()
|
||||||
|
@ -101,12 +124,13 @@ class GPUStatsWidget(Static):
|
||||||
async def on_mount(self) -> None:
|
async def on_mount(self) -> None:
|
||||||
'''Fires when stats widget 'mounted', behaves like on first showing'''
|
'''Fires when stats widget 'mounted', behaves like on first showing'''
|
||||||
# construct the table columns
|
# construct the table columns
|
||||||
for column in self.columns:
|
columns = list(self.get_column_data_mapping(None).keys())
|
||||||
|
for column in columns:
|
||||||
self.stats_table.add_column(label=column, key=column)
|
self.stats_table.add_column(label=column, key=column)
|
||||||
# do a one-off stat collection, populate table before the interval
|
# do a one-off stat collection, populate table before the interval
|
||||||
self.get_stats()
|
self.get_stats()
|
||||||
# stand up the stat-collecting interval, once per second
|
# stand up the stat-collecting interval, twice per second
|
||||||
self.timer_stats = self.set_interval(1, self.get_stats)
|
self.timer_stats = self.set_interval(0.5, self.get_stats)
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
"""Create child widgets."""
|
"""Create child widgets."""
|
||||||
|
@ -127,25 +151,11 @@ class GPUStatsWidget(Static):
|
||||||
def get_stats(self):
|
def get_stats(self):
|
||||||
'''Function to fetch stats / update the table for each AMD GPU found'''
|
'''Function to fetch stats / update the table for each AMD GPU found'''
|
||||||
for card in self.cards:
|
for card in self.cards:
|
||||||
power_stats = get_power_stats(card=card)
|
|
||||||
# annoyingly, must retain the styling used w/ the cols above
|
# annoyingly, must retain the styling used w/ the cols above
|
||||||
# otherwise stats won't update
|
# otherwise stats won't update
|
||||||
# noticed when fiddling 'style' below between new/update 'Text'
|
# noticed when fiddling 'style' below between new/update 'Text'
|
||||||
# should store their IDs on creation and map those instead
|
# should store their IDs on creation and map those instead
|
||||||
self.data = {
|
self.data = self.get_column_data_mapping(card)
|
||||||
"Card": card,
|
|
||||||
"Core clock": get_clock('core', card=card, format_freq=True),
|
|
||||||
"Memory clock": get_clock('memory', card=card, format_freq=True),
|
|
||||||
"Utilization": f'{get_gpu_usage(card=card)}%',
|
|
||||||
"Voltage": f'{get_voltage(card=card)}V',
|
|
||||||
"Power": f'{power_stats["average"]}W',
|
|
||||||
"[italic]Limit": f'{power_stats["limit"]}W',
|
|
||||||
"[italic]Default": f'{power_stats["default"]}W',
|
|
||||||
"[italic]Capability": f'{power_stats["capability"]}W',
|
|
||||||
"Fan RPM": f'{get_fan_rpm(card=card)}',
|
|
||||||
"Edge temp": f"{get_temp_stat(name='edge', card=card)}C",
|
|
||||||
"Junction temp": f"{get_temp_stat(name='junction', card=card)}C",
|
|
||||||
"Memory temp": f"{get_temp_stat(name='mem', card=card)}C"}
|
|
||||||
# handle the table data appopriately
|
# handle the table data appopriately
|
||||||
# if needs populated anew or updated
|
# if needs populated anew or updated
|
||||||
if self.table_needs_init:
|
if self.table_needs_init:
|
||||||
|
@ -167,10 +177,11 @@ class GPUStatsWidget(Static):
|
||||||
self.table_needs_init = False
|
self.table_needs_init = False
|
||||||
|
|
||||||
|
|
||||||
class AMDGPUStats(App):
|
class app(App): # pylint: disable=invalid-name
|
||||||
"""Textual-based tool to show AMDGPU statistics."""
|
"""Textual-based tool to show AMDGPU statistics."""
|
||||||
|
|
||||||
# apply stylesheet
|
# apply stylesheet; this is watched/dynamically reloaded
|
||||||
|
# can be edited (in installation dir) and seen live
|
||||||
CSS_PATH = 'style.css'
|
CSS_PATH = 'style.css'
|
||||||
|
|
||||||
# set the title - same as the class, but with spaces
|
# set the title - same as the class, but with spaces
|
||||||
|
@ -179,23 +190,25 @@ class AMDGPUStats(App):
|
||||||
# setup keybinds
|
# setup keybinds
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
Binding("c", "custom_dark", "Colors"),
|
Binding("c", "custom_dark", "Colors"),
|
||||||
Binding("l", "custom_log", "Logs"),
|
Binding("t", "custom_tab", "Tab switch"),
|
||||||
Binding("s", "custom_screenshot", "Screenshot"),
|
Binding("s", "custom_screenshot", "Screenshot"),
|
||||||
Binding("q", "quit", "Quit")
|
Binding("q", "quit", "Quit")
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# create an instance of the stats widget with all cards
|
||||||
stats_widget = GPUStatsWidget(cards=AMDGPU_CARDS,
|
stats_widget = GPUStatsWidget(cards=AMDGPU_CARDS,
|
||||||
name="stats_widget")
|
name="stats_widget")
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
"""Create child widgets for the app."""
|
"""Create child widgets for the app."""
|
||||||
yield Header(show_clock=True)
|
yield Header(show_clock=True)
|
||||||
yield Footer()
|
|
||||||
yield Container(self.stats_widget)
|
yield Container(self.stats_widget)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
def action_custom_dark(self) -> None:
|
def action_custom_dark(self) -> None:
|
||||||
"""An action to toggle dark mode.
|
"""An action to toggle dark mode.
|
||||||
|
|
||||||
Wraps 'action_toggle_dark' with logging and a refresh"""
|
Wraps 'action_toggle_dark' with our logging"""
|
||||||
self.app.dark = not self.app.dark
|
self.app.dark = not self.app.dark
|
||||||
self.update_log(f"[bold]Dark side: [italic]{self.app.dark}")
|
self.update_log(f"[bold]Dark side: [italic]{self.app.dark}")
|
||||||
|
|
||||||
|
@ -212,8 +225,8 @@ class AMDGPUStats(App):
|
||||||
self.screen.mount(Notification(message))
|
self.screen.mount(Notification(message))
|
||||||
self.update_log(message)
|
self.update_log(message)
|
||||||
|
|
||||||
def action_custom_log(self) -> None:
|
def action_custom_tab(self) -> None:
|
||||||
"""Toggle between the main screen and the LogScreen."""
|
"""Toggle between the 'Stats' and 'Logs' tabs"""
|
||||||
if self.stats_widget.tabbed_container.active == "tab_stats":
|
if self.stats_widget.tabbed_container.active == "tab_stats":
|
||||||
self.stats_widget.tabbed_container.active = 'tab_logs'
|
self.stats_widget.tabbed_container.active = 'tab_logs'
|
||||||
else:
|
else:
|
||||||
|
@ -222,12 +235,3 @@ class AMDGPUStats(App):
|
||||||
def update_log(self, message: str) -> None:
|
def update_log(self, message: str) -> None:
|
||||||
"""Update the TextLog widget with a new message."""
|
"""Update the TextLog widget with a new message."""
|
||||||
self.stats_widget.text_log.write(message)
|
self.stats_widget.text_log.write(message)
|
||||||
|
|
||||||
|
|
||||||
def start() -> None:
|
|
||||||
'''Spawns the textual UI only during CLI invocation / after argparse'''
|
|
||||||
if len(AMDGPU_CARDS) > 0:
|
|
||||||
app = AMDGPUStats(watch_css=True)
|
|
||||||
app.run()
|
|
||||||
else:
|
|
||||||
sys.exit('Could not find an AMD GPU, exiting.')
|
|
||||||
|
|
Reference in a new issue