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
|
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 }}
|
||||||
|
|
|
@ -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!
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
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%;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'
|
||||||
|
|
Reference in a new issue