diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index f4d9b83..bda3e4a 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -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 }} diff --git a/README.md b/README.md index aa39b3d..ce15469 100644 --- a/README.md +++ b/README.md @@ -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! diff --git a/pyproject.toml b/pyproject.toml index c0eb160..91a290b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] 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" diff --git a/requirements.txt b/requirements.txt index d77690a..ffd6e5e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ textual>=0.16.0 humanfriendly==10.0 +pyyaml==6.0 diff --git a/screens/graphs.svg b/screens/graphs.svg new file mode 100644 index 0000000..bfe5284 --- /dev/null +++ b/screens/graphs.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AMD GPU Stats + + + + + + + + + + AMD GPU Stats — cards: ['card0']18:57:42 + +StatsGraphsLogs +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +card0 +Core: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━69% +Power (limit): +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━15% +Power (capability): +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━13% + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + C  Colors  T  Tab switch  S  Screenshot  ↑  Scroll Logs  ↓  Scroll Logs  Q  Quit  + + + diff --git a/src/amdgpu_stats/style.css b/src/amdgpu_stats/style.css index 9102d5d..947085c 100644 --- a/src/amdgpu_stats/style.css +++ b/src/amdgpu_stats/style.css @@ -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; } diff --git a/src/amdgpu_stats/tui.py b/src/amdgpu_stats/tui.py index 0d5df63..1eee3da 100644 --- a/src/amdgpu_stats/tui.py +++ b/src/amdgpu_stats/tui.py @@ -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'