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 runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: ["3.9", "3.10", "3.11"] python-version: ["3.8", "3.9", "3.10", "3.11"]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}

View file

@ -1,8 +1,10 @@
# amdgpu_stats # amdgpu_stats
![pylint](https://github.com/joshlay/amdgpu_stats/actions/workflows/pylint.yml/badge.svg)
A Python module/TUI for AMD GPU statistics 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! 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] [tool.poetry]
name = "amdgpu-stats" name = "amdgpu-stats"
version = "0.1.17" version = "0.1.18"
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"
@ -20,9 +20,10 @@ classifiers = [
] ]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.9" python = "^3.8"
textual = ">=0.18.0" textual = ">=0.22.0"
humanfriendly = ">=10.0" humanfriendly = ">=10.0"
pyyaml = "^6.0"
[tool.poetry.scripts] [tool.poetry.scripts]
amdgpu-stats = "amdgpu_stats:textual_run" amdgpu-stats = "amdgpu_stats:textual_run"

View file

@ -1,2 +1,3 @@
textual>=0.16.0 textual>=0.16.0
humanfriendly==10.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%; width: 100%;
} }
.tab_graphs {
layout: grid;
}
.graph_section {
box-sizing: content-box;
}
Container { Container {
height: 1fr; height: 1fr;
} }
@ -70,6 +78,6 @@ Footer {
} }
ScrollBar { ScrollBar {
border-top: solid $panel-lighten-3; border-top: inner $panel-lighten-3;
border-bottom: solid $panel-lighten-3; border-bottom: inner $panel-lighten-3;
} }

View file

@ -16,20 +16,23 @@ Functions:
# pylint: disable=line-too-long # pylint: disable=line-too-long
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from yaml import dump
from rich.text import Text from rich.text import Text
from textual import work from textual import work
from textual.binding import Binding from textual.binding import Binding
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Container from textual.containers import Container, Vertical
from textual.widgets import ( from textual.widgets import (
Header,
Footer,
Static,
TextLog,
DataTable, DataTable,
Footer,
Header,
Label,
ProgressBar,
Static,
TabbedContent, TabbedContent,
TabPane, TabPane,
TextLog,
) )
from .utils import ( from .utils import (
@ -58,7 +61,6 @@ class Notification(Static):
'''Fires when notification is clicked, removes the widget''' '''Fires when notification is clicked, removes the widget'''
self.remove() self.remove()
class GPUStatsWidget(Static): class GPUStatsWidget(Static):
"""The main stats widget.""" """The main stats widget."""
@ -75,9 +77,9 @@ class GPUStatsWidget(Static):
"Utilization": "", "Utilization": "",
"Voltage": "", "Voltage": "",
"Power": "", "Power": "",
"[italic]Limit": "", "Limit": "",
"[italic]Default": "", "Default": "",
"[italic]Capability": "", "Capability": "",
"Fan RPM": "", "Fan RPM": "",
"Edge temp": "", "Edge temp": "",
"Junction temp": "", "Junction temp": "",
@ -90,9 +92,9 @@ class GPUStatsWidget(Static):
"Utilization": f'{get_gpu_usage(card=card)}%', "Utilization": f'{get_gpu_usage(card=card)}%',
"Voltage": f'{get_voltage(card=card)}V', "Voltage": f'{get_voltage(card=card)}V',
"Power": f'{get_power_stats(card=card)["average"]}W', "Power": f'{get_power_stats(card=card)["average"]}W',
"[italic]Limit": f'{get_power_stats(card=card)["limit"]}W', "Limit": f'{get_power_stats(card=card)["limit"]}W',
"[italic]Default": f'{get_power_stats(card=card)["default"]}W', "Default": f'{get_power_stats(card=card)["default"]}W',
"[italic]Capability": f'{get_power_stats(card=card)["capability"]}W', "Capability": f'{get_power_stats(card=card)["capability"]}W',
"Fan RPM": f'{get_fan_rpm(card=card)}', "Fan RPM": f'{get_fan_rpm(card=card)}',
"Edge temp": f"{get_temp_stat(name='edge', card=card)}C", "Edge temp": f"{get_temp_stat(name='edge', card=card)}C",
"Junction temp": f"{get_temp_stat(name='junction', card=card)}C", "Junction temp": f"{get_temp_stat(name='junction', card=card)}C",
@ -107,6 +109,19 @@ class GPUStatsWidget(Static):
timer_stats = 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
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): def __init__(self, *args, cards=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -119,18 +134,23 @@ class GPUStatsWidget(Static):
show_cursor=True, show_cursor=True,
name='stats_table', name='stats_table',
classes='stat_table') classes='stat_table')
self.tabbed_container = TabbedContent() self.tabbed_container = TabbedContent()
def on_mount(self) -> None: 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'''
self.update_log("[bold green]App started, logging begin!") self.update_log("[bold green]App started, logging begin!\n")
self.update_log(f"[bold]Discovered AMD GPUs: [/]{list(AMDGPU_CARDS)}")
# construct the table columns # construct the table columns
columns = list(self.get_column_data_mapping(None).keys()) columns = list(self.get_column_data_mapping(None).keys())
self.update_log('[bold]Stats table columns:')
for column in 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.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 # do a one-off stat collection, populate table before the interval
self.get_stats() self.get_stats()
# stand up the stat-collecting interval, twice per second # stand up the stat-collecting interval, twice per second
@ -141,6 +161,17 @@ class GPUStatsWidget(Static):
with self.tabbed_container: with self.tabbed_container:
with TabPane("Stats", id="tab_stats"): with TabPane("Stats", id="tab_stats"):
yield self.stats_table 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"): with TabPane("Logs", id="tab_logs"):
yield self.text_log yield self.text_log
@ -153,6 +184,12 @@ class GPUStatsWidget(Static):
'''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:
self.data = self.get_column_data_mapping(card) 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 # 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:
@ -163,7 +200,7 @@ class GPUStatsWidget(Static):
] ]
self.stats_table.add_row(*styled_row, key=card) self.stats_table.add_row(*styled_row, key=card)
hwmon_dir = AMDGPU_CARDS[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: else:
# Update existing rows, retaining styling/justification # Update existing rows, retaining styling/justification
for column, value in self.data.items(): for column, value in self.data.items():
@ -191,6 +228,10 @@ class app(App): # pylint: disable=invalid-name
Binding("c", "custom_dark", "Colors"), Binding("c", "custom_dark", "Colors"),
Binding("t", "custom_tab", "Tab switch"), Binding("t", "custom_tab", "Tab switch"),
Binding("s", "custom_screenshot", "Screenshot"), 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") Binding("q", "quit", "Quit")
] ]
@ -212,7 +253,21 @@ class app(App): # pylint: disable=invalid-name
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}")
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""" """Action that fires when the user presses 's' for a screenshot"""
# construct the screenshot elements: name (w/ ISO timestamp) + path # construct the screenshot elements: name (w/ ISO timestamp) + path
screen_name = ('amdgpu_stats_' + screen_name = ('amdgpu_stats_' +
@ -231,7 +286,10 @@ class app(App): # pylint: disable=invalid-name
def action_custom_tab(self) -> None: def action_custom_tab(self) -> None:
"""Toggle between the 'Stats' and 'Logs' tabs""" """Toggle between the 'Stats' and 'Logs' tabs"""
# walk/cycle the tabs
if self.stats_widget.tabbed_container.active == "tab_stats": 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' new_tab = 'tab_logs'
else: else:
new_tab = 'tab_stats' new_tab = 'tab_stats'