commit
63e0854a4a
7 changed files with 226 additions and 26 deletions
2
.github/workflows/pylint.yml
vendored
2
.github/workflows/pylint.yml
vendored
|
@ -7,7 +7,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10", "3.11"]
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
# amdgpu_stats
|
||||
![pylint](https://github.com/joshlay/amdgpu_stats/actions/workflows/pylint.yml/badge.svg)
|
||||
|
||||
A Python module/TUI for AMD GPU statistics
|
||||
|
||||
![Screenshot of main screen](https://raw.githubusercontent.com/joshlay/amdgpu_stats/master/screens/main.svg "Main screen")
|
||||
![Screenshot of the main stats table](https://raw.githubusercontent.com/joshlay/amdgpu_stats/master/screens/main.svg "Main screen")
|
||||
![Screenshot of the 'graphing' scroll bars](https://raw.githubusercontent.com/joshlay/amdgpu_stats/master/screens/graphs.svg "Graphs")
|
||||
|
||||
Tested _only_ on `RX6000` series cards; APUs and more _may_ be supported. Please file an issue if finding incompatibility!
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[tool.poetry]
|
||||
name = "amdgpu-stats"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
description = "A module/TUI for AMD GPU statistics"
|
||||
authors = ["Josh Lay <pypi@jlay.io>"]
|
||||
repository = "https://github.com/joshlay/amdgpu_stats"
|
||||
|
@ -20,9 +20,10 @@ classifiers = [
|
|||
]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.9"
|
||||
textual = ">=0.18.0"
|
||||
python = "^3.8"
|
||||
textual = ">=0.22.0"
|
||||
humanfriendly = ">=10.0"
|
||||
pyyaml = "^6.0"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
amdgpu-stats = "amdgpu_stats:textual_run"
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
textual>=0.16.0
|
||||
humanfriendly==10.0
|
||||
pyyaml==6.0
|
||||
|
|
130
screens/graphs.svg
Normal file
130
screens/graphs.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 23 KiB |
|
@ -16,6 +16,14 @@ GPUStatsWidget {
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.tab_graphs {
|
||||
layout: grid;
|
||||
}
|
||||
|
||||
.graph_section {
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
Container {
|
||||
height: 1fr;
|
||||
}
|
||||
|
@ -70,6 +78,6 @@ Footer {
|
|||
}
|
||||
|
||||
ScrollBar {
|
||||
border-top: solid $panel-lighten-3;
|
||||
border-bottom: solid $panel-lighten-3;
|
||||
border-top: inner $panel-lighten-3;
|
||||
border-bottom: inner $panel-lighten-3;
|
||||
}
|
||||
|
|
|
@ -16,20 +16,23 @@ Functions:
|
|||
# pylint: disable=line-too-long
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from yaml import dump
|
||||
|
||||
from rich.text import Text
|
||||
from textual import work
|
||||
from textual.binding import Binding
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Container
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.widgets import (
|
||||
Header,
|
||||
Footer,
|
||||
Static,
|
||||
TextLog,
|
||||
DataTable,
|
||||
Footer,
|
||||
Header,
|
||||
Label,
|
||||
ProgressBar,
|
||||
Static,
|
||||
TabbedContent,
|
||||
TabPane,
|
||||
TextLog,
|
||||
)
|
||||
|
||||
from .utils import (
|
||||
|
@ -58,7 +61,6 @@ class Notification(Static):
|
|||
'''Fires when notification is clicked, removes the widget'''
|
||||
self.remove()
|
||||
|
||||
|
||||
class GPUStatsWidget(Static):
|
||||
"""The main stats widget."""
|
||||
|
||||
|
@ -75,9 +77,9 @@ class GPUStatsWidget(Static):
|
|||
"Utilization": "",
|
||||
"Voltage": "",
|
||||
"Power": "",
|
||||
"[italic]Limit": "",
|
||||
"[italic]Default": "",
|
||||
"[italic]Capability": "",
|
||||
"Limit": "",
|
||||
"Default": "",
|
||||
"Capability": "",
|
||||
"Fan RPM": "",
|
||||
"Edge temp": "",
|
||||
"Junction temp": "",
|
||||
|
@ -90,9 +92,9 @@ class GPUStatsWidget(Static):
|
|||
"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',
|
||||
"Limit": f'{get_power_stats(card=card)["limit"]}W',
|
||||
"Default": f'{get_power_stats(card=card)["default"]}W',
|
||||
"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",
|
||||
|
@ -107,6 +109,19 @@ class GPUStatsWidget(Static):
|
|||
timer_stats = None
|
||||
# mark the table as needing initialization (with rows)
|
||||
table_needs_init = True
|
||||
card_bars = []
|
||||
for card in AMDGPU_CARDS:
|
||||
card_bars.append((card,
|
||||
ProgressBar(total=100.0,
|
||||
show_eta=False,
|
||||
id='bar_' + card + '_util'),
|
||||
ProgressBar(total=100.0,
|
||||
show_eta=False,
|
||||
id='bar_' + card + '_poweravg'),
|
||||
ProgressBar(total=100.0,
|
||||
show_eta=False,
|
||||
id='bar_' + card + '_powercap'))
|
||||
)
|
||||
|
||||
def __init__(self, *args, cards=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
@ -119,18 +134,23 @@ class GPUStatsWidget(Static):
|
|||
show_cursor=True,
|
||||
name='stats_table',
|
||||
classes='stat_table')
|
||||
|
||||
self.tabbed_container = TabbedContent()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
'''Fires when stats widget 'mounted', behaves like on first showing'''
|
||||
self.update_log("[bold green]App started, logging begin!")
|
||||
self.update_log(f"[bold]Discovered AMD GPUs: [/]{list(AMDGPU_CARDS)}")
|
||||
self.update_log("[bold green]App started, logging begin!\n")
|
||||
# construct the table columns
|
||||
columns = list(self.get_column_data_mapping(None).keys())
|
||||
self.update_log('[bold]Stats table columns:')
|
||||
for column in columns:
|
||||
self.stats_table.add_column(label=column, key=column)
|
||||
self.update_log(f' - "{column}"')
|
||||
if column in ['Limit', 'Default', 'Capability']:
|
||||
self.stats_table.add_column(label='[italic]' + column,
|
||||
key=column)
|
||||
else:
|
||||
self.stats_table.add_column(label=column, key=column)
|
||||
# self.update_log(f' - "{column}"')
|
||||
self.update_log('[bold]Stat columns:')
|
||||
self.update_log(dump(data=columns, default_flow_style=False, sort_keys=True))
|
||||
# do a one-off stat collection, populate table before the interval
|
||||
self.get_stats()
|
||||
# stand up the stat-collecting interval, twice per second
|
||||
|
@ -141,6 +161,17 @@ class GPUStatsWidget(Static):
|
|||
with self.tabbed_container:
|
||||
with TabPane("Stats", id="tab_stats"):
|
||||
yield self.stats_table
|
||||
with TabPane("Graphs", id="tab_graphs", classes="tab_graphs"):
|
||||
for card, util_bar, power_bar_avg, power_bar_cap in self.card_bars:
|
||||
yield Vertical(
|
||||
Label(f'[bold]{card}'),
|
||||
Label('Core:'),
|
||||
util_bar,
|
||||
Label('Power [italic](limit)[/i]:'),
|
||||
power_bar_avg,
|
||||
Label('Power [italic](capability)[/i]:'),
|
||||
power_bar_cap,
|
||||
classes='graph_section')
|
||||
with TabPane("Logs", id="tab_logs"):
|
||||
yield self.text_log
|
||||
|
||||
|
@ -153,6 +184,12 @@ class GPUStatsWidget(Static):
|
|||
'''Function to fetch stats / update the table for each AMD GPU found'''
|
||||
for card in self.cards:
|
||||
self.data = self.get_column_data_mapping(card)
|
||||
# Update usage bars
|
||||
self.query_one(f'#bar_{card}_util').update(total=100, progress=float(self.data['Utilization'].replace('%', '')))
|
||||
self.query_one(f'#bar_{card}_poweravg').update(total=float(self.data['Limit'].replace('W', '')),
|
||||
progress=float(self.data['Power'].replace('W', '')))
|
||||
self.query_one(f'#bar_{card}_powercap').update(total=float(self.data['Capability'].replace('W', '')),
|
||||
progress=float(self.data['Power'].replace('W', '')))
|
||||
# handle the table data appopriately
|
||||
# if needs populated anew or updated
|
||||
if self.table_needs_init:
|
||||
|
@ -163,7 +200,7 @@ class GPUStatsWidget(Static):
|
|||
]
|
||||
self.stats_table.add_row(*styled_row, key=card)
|
||||
hwmon_dir = AMDGPU_CARDS[card]
|
||||
self.update_log(f"[bold]Stats table: [/]added row for '{card}', info dir: '{hwmon_dir}'")
|
||||
self.update_log(f"Added row for '{card}', stats dir: '{hwmon_dir}'")
|
||||
else:
|
||||
# Update existing rows, retaining styling/justification
|
||||
for column, value in self.data.items():
|
||||
|
@ -191,6 +228,10 @@ class app(App): # pylint: disable=invalid-name
|
|||
Binding("c", "custom_dark", "Colors"),
|
||||
Binding("t", "custom_tab", "Tab switch"),
|
||||
Binding("s", "custom_screenshot", "Screenshot"),
|
||||
Binding("up,k", "custom_logscroll('up')", "Scroll Logs", ),
|
||||
Binding("down,j", "custom_logscroll('down')", "Scroll Logs"),
|
||||
Binding("pageup", "custom_logscroll('pageup')", "", show=False),
|
||||
Binding("pagedown", "custom_logscroll('pagedown')", "", show=False),
|
||||
Binding("q", "quit", "Quit")
|
||||
]
|
||||
|
||||
|
@ -212,7 +253,21 @@ class app(App): # pylint: disable=invalid-name
|
|||
self.app.dark = not self.app.dark
|
||||
self.update_log(f"[bold]Dark side: [italic]{self.app.dark}")
|
||||
|
||||
def action_custom_screenshot(self, screen_dir: str = '/tmp') -> None:
|
||||
async def action_custom_logscroll(self, direction: str) -> None:
|
||||
"""Action that handles scrolling of the logging widget
|
||||
|
||||
'j', 'k', 'Up'/'Down' arrows handle line-by-line
|
||||
Page Up/Down do... pages"""
|
||||
if direction == "pageup":
|
||||
self.stats_widget.text_log.scroll_page_up(animate=True, speed=None, duration=0.175)
|
||||
elif direction == "up":
|
||||
self.stats_widget.text_log.scroll_up(animate=False)
|
||||
elif direction == "pagedown":
|
||||
self.stats_widget.text_log.scroll_page_down(animate=True, speed=None, duration=0.175)
|
||||
elif direction == "down":
|
||||
self.stats_widget.text_log.scroll_down(animate=False)
|
||||
|
||||
async def action_custom_screenshot(self, screen_dir: str = '/tmp') -> None:
|
||||
"""Action that fires when the user presses 's' for a screenshot"""
|
||||
# construct the screenshot elements: name (w/ ISO timestamp) + path
|
||||
screen_name = ('amdgpu_stats_' +
|
||||
|
@ -231,7 +286,10 @@ class app(App): # pylint: disable=invalid-name
|
|||
|
||||
def action_custom_tab(self) -> None:
|
||||
"""Toggle between the 'Stats' and 'Logs' tabs"""
|
||||
# walk/cycle the tabs
|
||||
if self.stats_widget.tabbed_container.active == "tab_stats":
|
||||
new_tab = 'tab_graphs'
|
||||
elif self.stats_widget.tabbed_container.active == "tab_graphs":
|
||||
new_tab = 'tab_logs'
|
||||
else:
|
||||
new_tab = 'tab_stats'
|
||||
|
|
Reference in a new issue