From f45d8c9b34bb9d5e757ff1068f79658de067d4b3 Mon Sep 17 00:00:00 2001 From: Jessey van Offeren Date: Fri, 22 May 2026 09:04:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(gui):=20bring=20back=20the=20Inventory=20p?= =?UTF-8?q?age=20=E2=80=94=200.17.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the GUI Inventory page (removed in 0.7.2 for the CLI). Sidebar Inventory → System/CPU/Firmware/Memory/GPU/Storage/Display cards, Copy Markdown / Save… / Refresh; root-only dmidecode details (motherboard/BIOS/RAM) fill in after launch elevation. Reuses the existing M5 core/inventory.py; CLI unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 8 ++ pyproject.toml | 2 +- src/rigdoctor/__init__.py | 2 +- src/rigdoctor/gui/inventory_page.py | 150 ++++++++++++++++++++++++++++ src/rigdoctor/gui/main_window.py | 16 +-- 5 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 src/rigdoctor/gui/inventory_page.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 88e3444..cc5dad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to RigDoctor are recorded here. Format follows (`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git release tag (so the auto-updater, D18, can compare versions). +## [0.17.0] - 2026-05-22 +### Added +- **Inventory page is back in the GUI** (it was removed in 0.7.2 in favor of the CLI). Sidebar + **Inventory** → System / CPU / Firmware / Memory / GPU / Storage / Display as cards, with + **Copy Markdown** and **Save…** for pasting into forum/bug reports, and **Refresh**. Root-only + details (motherboard/BIOS/RAM modules via dmidecode) fill in after the launch password prompt. + Backed by the existing M5 `core/inventory.py` — the CLI `rigdoctor inventory` is unchanged. + ## [0.16.0] - 2026-05-22 ### Added - **Automatic crash-capture via a Steam launch wrapper (M6/D12).** Set `rigdoctor wrap diff --git a/pyproject.toml b/pyproject.toml index f330021..116afd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rigdoctor" -version = "0.16.0" +version = "0.17.0" description = "Modular hardware monitoring & crash diagnostics for Linux gamers." readme = "README.md" requires-python = ">=3.11" diff --git a/src/rigdoctor/__init__.py b/src/rigdoctor/__init__.py index 0878c5f..ae719c7 100644 --- a/src/rigdoctor/__init__.py +++ b/src/rigdoctor/__init__.py @@ -1,3 +1,3 @@ """RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers.""" -__version__ = "0.16.0" +__version__ = "0.17.0" diff --git a/src/rigdoctor/gui/inventory_page.py b/src/rigdoctor/gui/inventory_page.py new file mode 100644 index 0000000..9476bc0 --- /dev/null +++ b/src/rigdoctor/gui/inventory_page.py @@ -0,0 +1,150 @@ +"""Inventory page (M5 in the GUI): system inventory with copy/save + admin re-collect.""" + +from __future__ import annotations + +import os +import threading + +from PySide6.QtCore import Qt, QTimer, Signal +from PySide6.QtWidgets import ( + QApplication, + QFileDialog, + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QPushButton, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from ..core import inventory + + +def _section_card(section) -> QFrame: + card = QFrame() + card.setObjectName("Card") + layout = QVBoxLayout(card) + layout.setContentsMargins(16, 12, 16, 12) + layout.setSpacing(6) + title = QLabel(section.title) + title.setStyleSheet("font-weight: 700; background: transparent;") + layout.addWidget(title) + grid = QGridLayout() + grid.setColumnStretch(1, 1) + grid.setHorizontalSpacing(14) + grid.setVerticalSpacing(4) + for row, (key, value) in enumerate(section.items): + k = QLabel(key) + k.setObjectName("Muted") + v = QLabel(value) + v.setWordWrap(True) + v.setStyleSheet("background: transparent;") + grid.addWidget(k, row, 0) + grid.addWidget(v, row, 1) + layout.addLayout(grid) + return card + + +class InventoryPage(QWidget): + _result = Signal(object) # list[Section] + + def __init__(self) -> None: + super().__init__() + self.setObjectName("Page") + self._sections: list = [] + self._result.connect(self._render) + + root = QVBoxLayout(self) + root.setContentsMargins(20, 18, 20, 18) + root.setSpacing(16) + + header = QHBoxLayout() + title = QLabel("Inventory") + title.setObjectName("PageTitle") + header.addWidget(title) + header.addStretch(1) + self._status = QLabel("") + self._status.setObjectName("Muted") + header.addWidget(self._status) + self._copy_btn = QPushButton("Copy Markdown") + self._copy_btn.clicked.connect(self._copy) + header.addWidget(self._copy_btn) + self._save_btn = QPushButton("Save…") + self._save_btn.clicked.connect(self._save) + header.addWidget(self._save_btn) + self._refresh_btn = QPushButton("Refresh") + self._refresh_btn.setObjectName("PrimaryButton") + self._refresh_btn.clicked.connect(self._run) + header.addWidget(self._refresh_btn) + root.addLayout(header) + + self._scroll = scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setStyleSheet("background: transparent;") + self._container = QWidget() + self._list = QVBoxLayout(self._container) + self._list.setContentsMargins(0, 0, 0, 0) + self._list.setSpacing(12) + self._list.setAlignment(Qt.AlignmentFlag.AlignTop) + scroll.setWidget(self._container) + root.addWidget(scroll, 1) + + QTimer.singleShot(300, self._run) + + def _run(self) -> None: + self._busy("Collecting…") + threading.Thread(target=self._work, daemon=True).start() + + def _work(self) -> None: + try: + sections = inventory.collect() + except Exception: + sections = [] + self._result.emit(sections) + + def _busy(self, text: str) -> None: + self._status.setText(text) + for b in (self._refresh_btn, self._copy_btn, self._save_btn): + b.setEnabled(False) + + def _render(self, sections) -> None: + self._refresh_btn.setEnabled(True) + self._copy_btn.setEnabled(True) + self._save_btn.setEnabled(True) + if sections is None: # collection failed — keep current + self._status.setText("collection failed") + return + if sections == self._sections: # unchanged — don't rebuild (would jump scroll) + self._status.setText("") + return + + scroll_pos = self._scroll.verticalScrollBar().value() + self._sections = sections + while self._list.count(): + item = self._list.takeAt(0) + w = item.widget() + if w is not None: + w.deleteLater() + for section in sections: + self._list.addWidget(_section_card(section)) + self._list.addStretch(1) + self._status.setText("") + # restore scroll after the layout settles so re-renders don't yank to the top + QTimer.singleShot(0, lambda: self._scroll.verticalScrollBar().setValue(scroll_pos)) + + def _copy(self) -> None: + if self._sections: + QApplication.clipboard().setText(inventory.render_markdown(self._sections)) + self._status.setText("copied as Markdown") + + def _save(self) -> None: + if not self._sections: + return + path, _ = QFileDialog.getSaveFileName(self, "Save inventory", "rigdoctor-inventory.md", "Markdown (*.md)") + if path: + with open(path, "w", encoding="utf-8") as f: + f.write(inventory.render_markdown(self._sections)) + self._status.setText(f"saved {os.path.basename(path)}") diff --git a/src/rigdoctor/gui/main_window.py b/src/rigdoctor/gui/main_window.py index 725f228..bc99f3d 100644 --- a/src/rigdoctor/gui/main_window.py +++ b/src/rigdoctor/gui/main_window.py @@ -31,6 +31,7 @@ from .dashboard import Dashboard from .environment_page import EnvironmentPage from .games_page import GamesPage from .health_page import HealthPage +from .inventory_page import InventoryPage from .notifications_page import NotificationsPage from .recorder_page import RecorderPage from .setup_page import SetupPage @@ -38,7 +39,7 @@ from .share_page import SharePage from .theme import ACCENT, GOOD, MUTED from .worker import SamplerWorker -_NAV_ITEMS = ["Dashboard", "Logs", "Health", "Games", "Environment", "Setup", "Notifications", "Share"] +_NAV_ITEMS = ["Dashboard", "Logs", "Health", "Games", "Environment", "Inventory", "Setup", "Notifications", "Share"] class MainWindow(QMainWindow): @@ -71,6 +72,7 @@ class MainWindow(QMainWindow): self.games_page = GamesPage() self.games_page.new_count_changed.connect(self._set_games_badge) self.environment_page = EnvironmentPage() + self.inventory_page = InventoryPage() self.setup_page = SetupPage() self.notifications_page = NotificationsPage() self.notifications_page.changed.connect(self._apply_alert_settings) @@ -80,9 +82,10 @@ class MainWindow(QMainWindow): self._stack.addWidget(self.health_page) # 2 Health self._stack.addWidget(self.games_page) # 3 Games self._stack.addWidget(self.environment_page) # 4 Environment - self._stack.addWidget(self.setup_page) # 5 Setup - self._stack.addWidget(self.notifications_page) # 6 Notifications - self._stack.addWidget(self.share_page) # 7 Share + self._stack.addWidget(self.inventory_page) # 5 Inventory + self._stack.addWidget(self.setup_page) # 6 Setup + self._stack.addWidget(self.notifications_page) # 7 Notifications + self._stack.addWidget(self.share_page) # 8 Share content_layout.addWidget(self._stack) layout.addWidget(self._build_sidebar()) @@ -234,9 +237,10 @@ class MainWindow(QMainWindow): self._elevated.emit() def _on_elevated(self) -> None: - # Re-run Health now that root-only SMART data is available. (dmidecode is still - # collected and used by the relay guest view + the CLI `rigdoctor inventory`.) + # Re-run Health + Inventory now that root-only data is available (SMART for Health, + # dmidecode motherboard/BIOS/RAM for Inventory). self.health_page._run() + self.inventory_page._run() def _set_games_badge(self, count: int) -> None: btn = self._nav_buttons.get("Games")