Archived
1
1
Fork 0

Merge pull request #31 from joshlay/0.1.18

0.1.18
This commit is contained in:
Josh Lay 2023-06-03 02:07:25 -05:00 committed by GitHub
commit 63e0854a4a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 226 additions and 26 deletions

View file

@ -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 }}

View file

@ -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!

View file

@ -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"

View file

@ -1,2 +1,3 @@
textual>=0.16.0
humanfriendly==10.0
pyyaml==6.0

130
screens/graphs.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -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;
}

View file

@ -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:
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(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'