diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dd068c..580281c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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.3.0] - 2026-05-21 +### Added +- **System inventory (M5)**: CPU, GPU (model/driver/VBIOS/VRAM/PCIe), motherboard/BIOS, RAM + (total + modules), storage, kernel, and display server. CLI `rigdoctor inventory` + (`--json` / `--markdown` / `--output`) and a GUI **Inventory** tab with Copy-as-Markdown, + Save, and "Run with admin" (for `dmidecode` board/BIOS/RAM details). Fills the last GUI tab. + ## [0.2.0] - 2026-05-21 ### Added - **"Check for updates" button** in the sidebar — force an immediate version check instead of diff --git a/docs/MODULES.md b/docs/MODULES.md index e72c6d7..079908f 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -13,7 +13,7 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done | M4 | Health report (log scan) | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | 🟨 | | M2 | Live monitor (TUI) | Monitoring | none (stdlib curses) | all | P1 | ⬜ | | M8 | Alerting | Monitoring | libnotify (opt) | all | P2 | ⬜ | -| M5 | System inventory | Diagnostics | none (opt: lm-sensors, dmidecode) | all | P1 | ⬜ | +| M5 | System inventory | Diagnostics | none (opt: lm-sensors, dmidecode) | all | P1 | 🟨 | | M6 | Gaming env checks | Diagnostics | none | all | P2 | ⬜ | | M10 | Desktop GUI | Desktop UI | **python3-pyside6** | all | P2 | 🟨 | | M11 | Tray / menu-bar applet | Desktop UI | **python3-pyside6** (+ AppIndicator on GNOME) | all | P2 | ⬜ | diff --git a/pyproject.toml b/pyproject.toml index 3021590..219f76d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rigdoctor" -version = "0.2.0" +version = "0.3.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 3744a94..20d7a5c 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.2.0" +__version__ = "0.3.0" diff --git a/src/rigdoctor/cli.py b/src/rigdoctor/cli.py index ce6d7d5..7c7da10 100644 --- a/src/rigdoctor/cli.py +++ b/src/rigdoctor/cli.py @@ -295,6 +295,24 @@ def cmd_uninstall(args) -> int: return 0 +def cmd_inventory(args) -> int: + from .core import inventory + + sections = inventory.collect() + if args.json: + text = inventory.render_json(sections) + elif args.markdown: + text = inventory.render_markdown(sections) + else: + text = inventory.render_text(sections) + if args.output: + Path(args.output).write_text(text) + print(f"Wrote {args.output}") + else: + print(text) + return 0 + + def cmd_report(args) -> int: from dataclasses import asdict @@ -371,6 +389,12 @@ def build_parser() -> argparse.ArgumentParser: rep = sub.add_parser("report", help="health report (M4): scan logs/SMART/driver for issues") rep.add_argument("--json", action="store_true", help="output JSON instead of text") rep.set_defaults(func=cmd_report) + + inv = sub.add_parser("inventory", help="system inventory (M5): export hardware/OS details") + inv.add_argument("--json", action="store_true", help="output JSON") + inv.add_argument("--markdown", action="store_true", help="output Markdown (for forum/bug reports)") + inv.add_argument("-o", "--output", default=None, help="write to a file instead of stdout") + inv.set_defaults(func=cmd_inventory) return p diff --git a/src/rigdoctor/core/inventory.py b/src/rigdoctor/core/inventory.py new file mode 100644 index 0000000..5fb76d6 --- /dev/null +++ b/src/rigdoctor/core/inventory.py @@ -0,0 +1,203 @@ +"""System inventory (M5): collect hardware/OS details, exportable to Markdown/JSON. + +Stdlib + tools already used elsewhere (nvidia-smi, lspci, lsblk, dmidecode). Every +probe degrades gracefully; board/BIOS/RAM-module details need dmidecode as root. +""" + +from __future__ import annotations + +import json +import os +import platform +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path + +from .. import __version__ +from . import sysenv + + +@dataclass +class Section: + title: str + items: list[tuple[str, str]] + + +def _run(cmd: list[str], timeout: float = 12.0) -> str: + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + if proc.returncode == 0: + return proc.stdout + except (subprocess.SubprocessError, OSError): + pass + return "" + + +def _system() -> Section: + u = os.uname() + return Section("System", [ + ("Distro", sysenv.distro_name()), + ("Kernel", u.release), + ("Architecture", u.machine), + ("Hostname", u.nodename), + ("Python", platform.python_version()), + ("RigDoctor", __version__), + ]) + + +def _cpu() -> Section: + model = "?" + threads = 0 + core_ids: set[tuple[str, str]] = set() + phys = "0" + try: + for line in Path("/proc/cpuinfo").read_text().splitlines(): + if line.startswith("model name") and model == "?": + model = line.split(":", 1)[1].strip() + elif line.startswith("processor"): + threads += 1 + elif line.startswith("physical id"): + phys = line.split(":", 1)[1].strip() + elif line.startswith("core id"): + core_ids.add((phys, line.split(":", 1)[1].strip())) + except OSError: + pass + items = [("Model", model)] + if core_ids: + items.append(("Cores", str(len(core_ids)))) + items.append(("Threads", str(threads or os.cpu_count() or "?"))) + return Section("CPU", items) + + +def _firmware(dmi: dict) -> Section: + board = dmi.get("baseboard", {}) + bios = dmi.get("bios", {}) + items: list[tuple[str, str]] = [] + if board: + items.append(("Motherboard", f"{board.get('Manufacturer', '')} {board.get('Product Name', '')}".strip())) + if bios: + items.append(("BIOS", f"{bios.get('Vendor', '')} {bios.get('Version', '')}".strip())) + if bios.get("Release Date"): + items.append(("BIOS date", bios["Release Date"])) + if not items: + items = [("Motherboard / BIOS", "run with admin (dmidecode needs root)")] + return Section("Firmware", items) + + +def _memory(dmi: dict) -> Section: + items: list[tuple[str, str]] = [] + try: + for line in Path("/proc/meminfo").read_text().splitlines(): + if line.startswith("MemTotal"): + items.append(("Total", f"{int(line.split()[1]) / 1024 / 1024:.1f} GB")) + break + except (OSError, ValueError, IndexError): + pass + modules = dmi.get("memory", []) + if modules: + items.append(("Modules", str(len(modules)))) + for i, m in enumerate(modules): + desc = " · ".join(p for p in (m.get("Size"), m.get("Type"), m.get("Speed"), m.get("Part Number")) if p) + items.append((f"Slot {i}", desc)) + elif shutil.which("dmidecode"): + items.append(("Modules", "run with admin for module details")) + return Section("Memory", items) + + +def _gpu() -> Section: + if shutil.which("nvidia-smi"): + out = _run([ + "nvidia-smi", + "--query-gpu=name,driver_version,vbios_version,memory.total,pcie.link.gen.max,pcie.link.width.max", + "--format=csv,noheader", + ]) + line = out.strip().splitlines()[0] if out.strip() else "" + if line: + cols = [c.strip() for c in line.split(",")] + keys = ["Name", "Driver", "VBIOS", "VRAM", "PCIe gen (max)", "PCIe width (max)"] + return Section("GPU", list(zip(keys, cols))) + out = _run(["lspci"]) + gpus = [ln.split(":", 2)[-1].strip() for ln in out.splitlines() + if "VGA compatible controller" in ln or "3D controller" in ln] + return Section("GPU", [("Device", g) for g in gpus] or [("Device", "unknown")]) + + +def _storage() -> Section: + items: list[tuple[str, str]] = [] + # TYPE first so MODEL (which can contain spaces) is the trailing field. + out = _run(["lsblk", "-dn", "-o", "TYPE,NAME,SIZE,MODEL"]) + for line in out.strip().splitlines(): + parts = line.split(None, 3) + if len(parts) < 3 or parts[0] != "disk": # skip loop/zram/rom devices + continue + name, size = parts[1], parts[2] + model = parts[3] if len(parts) > 3 else "" + items.append((name, f"{model} ({size})".strip())) + return Section("Storage", items or [("Disks", "unknown")]) + + +def _display() -> Section: + return Section("Display", [ + ("Session", os.environ.get("XDG_SESSION_TYPE", "unknown")), + ("Desktop", os.environ.get("XDG_CURRENT_DESKTOP") or os.environ.get("DESKTOP_SESSION", "unknown")), + ]) + + +def _dmidecode() -> dict: + if not shutil.which("dmidecode"): + return {} + out = _run(["dmidecode", "-t", "baseboard", "-t", "bios", "-t", "memory"], timeout=15) + if not out.strip(): + return {} + result: dict = {"baseboard": {}, "bios": {}, "memory": []} + for block in out.split("Handle "): + lines = block.splitlines() + if len(lines) < 2: + continue + title = lines[1].strip() + kv: dict[str, str] = {} + for ln in lines[2:]: + if ln.startswith("\t") and ":" in ln: + key, _, value = ln.strip().partition(":") + kv[key.strip()] = value.strip() + if title == "Base Board Information": + result["baseboard"] = kv + elif title == "BIOS Information": + result["bios"] = kv + elif title == "Memory Device" and kv.get("Size") and kv["Size"] != "No Module Installed": + result["memory"].append(kv) + return result + + +def collect() -> list[Section]: + dmi = _dmidecode() + return [_system(), _cpu(), _firmware(dmi), _memory(dmi), _gpu(), _storage(), _display()] + + +def to_dict(sections: list[Section]) -> dict: + return {s.title: dict(s.items) for s in sections} + + +def from_dict(data: dict) -> list[Section]: + return [Section(title, list(items.items())) for title, items in data.items()] + + +def render_markdown(sections: list[Section]) -> str: + out = ["# RigDoctor system inventory", ""] + for s in sections: + out.append(f"## {s.title}") + out += [f"- **{k}:** {v}" for k, v in s.items] + out.append("") + return "\n".join(out).strip() + "\n" + + +def render_text(sections: list[Section]) -> str: + blocks = [] + for s in sections: + blocks.append("\n".join([s.title] + [f" {k:<18} {v}" for k, v in s.items])) + return "\n\n".join(blocks) + + +def render_json(sections: list[Section]) -> str: + return json.dumps(to_dict(sections), indent=2, ensure_ascii=False) diff --git a/src/rigdoctor/gui/inventory_page.py b/src/rigdoctor/gui/inventory_page.py new file mode 100644 index 0000000..cfdfc89 --- /dev/null +++ b/src/rigdoctor/gui/inventory_page.py @@ -0,0 +1,169 @@ +"""Inventory page (M5 in the GUI): system inventory with copy/save + admin re-collect.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +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 +from .theme import MUTED + + +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._admin_btn = QPushButton("Run with admin") + self._admin_btn.setToolTip("Re-collect with root for motherboard/BIOS/RAM details (dmidecode)") + self._admin_btn.setEnabled(shutil.which("pkexec") is not None) + self._admin_btn.clicked.connect(self._run_admin) + header.addWidget(self._admin_btn) + 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) + + 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 _run_admin(self) -> None: + self._busy("Collecting with admin (you'll be prompted)…") + threading.Thread(target=self._work_admin, daemon=True).start() + + def _work_admin(self) -> None: + cli = os.path.join(os.path.dirname(sys.executable), "rigdoctor") + cmd = [cli, "inventory", "--json"] if os.path.exists(cli) else [sys.executable, "-m", "rigdoctor", "inventory", "--json"] + try: + proc = subprocess.run(["pkexec", *cmd], capture_output=True, text=True, timeout=120) + sections = inventory.from_dict(json.loads(proc.stdout)) if proc.returncode == 0 else None + except Exception: + sections = None + self._result.emit(sections) + + def _busy(self, text: str) -> None: + self._status.setText(text) + for b in (self._refresh_btn, self._admin_btn, self._copy_btn, self._save_btn): + b.setEnabled(False) + + def _render(self, sections) -> None: + self._refresh_btn.setEnabled(True) + self._admin_btn.setEnabled(shutil.which("pkexec") is not None) + self._copy_btn.setEnabled(True) + self._save_btn.setEnabled(True) + if sections is None: # admin run cancelled/failed — keep current + self._status.setText("admin run cancelled") + return + + 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("") + + 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 02656b1..bb2c7e4 100644 --- a/src/rigdoctor/gui/main_window.py +++ b/src/rigdoctor/gui/main_window.py @@ -28,15 +28,13 @@ from ..config import load_config from ..core import updates from .dashboard import Dashboard from .health_page import HealthPage +from .inventory_page import InventoryPage from .recorder_page import RecorderPage from .setup_page import SetupPage from .theme import ACCENT, GOOD, MUTED from .worker import SamplerWorker _NAV_ITEMS = ["Dashboard", "Logs", "Health", "Setup", "Inventory"] -_PLACEHOLDERS = { - "Inventory": "System inventory (M5) — CPU/GPU/board/RAM/drivers — lands here.", -} class MainWindow(QMainWindow): @@ -65,11 +63,12 @@ class MainWindow(QMainWindow): self.recorder_page = RecorderPage() self.health_page = HealthPage() self.setup_page = SetupPage() + self.inventory_page = InventoryPage() self._stack.addWidget(self.dashboard) # 0 Dashboard self._stack.addWidget(self.recorder_page) # 1 Logs self._stack.addWidget(self.health_page) # 2 Health self._stack.addWidget(self.setup_page) # 3 Setup - self._stack.addWidget(self._placeholder_page("Inventory", _PLACEHOLDERS["Inventory"])) # 4 + self._stack.addWidget(self.inventory_page) # 4 Inventory content_layout.addWidget(self._stack) layout.addWidget(self._build_sidebar()) @@ -260,29 +259,6 @@ class MainWindow(QMainWindow): else: # UP_TO_DATE self._update_label.setText("up-to-date") - def _placeholder_page(self, title: str, description: str) -> QWidget: - page = QWidget() - page.setObjectName("Page") - v = QVBoxLayout(page) - v.setContentsMargins(20, 18, 20, 18) - v.setSpacing(16) - head = QLabel(title) - head.setObjectName("PageTitle") - v.addWidget(head) - - card = QFrame() - card.setObjectName("Card") - cv = QVBoxLayout(card) - cv.setContentsMargins(24, 48, 24, 48) - msg = QLabel(description) - msg.setObjectName("Muted") - msg.setWordWrap(True) - msg.setAlignment(Qt.AlignmentFlag.AlignCenter) - cv.addWidget(msg) - v.addWidget(card) - v.addStretch(1) - return page - def closeEvent(self, event) -> None: # noqa: N802 (Qt override) self._worker.stop() super().closeEvent(event) diff --git a/tests/test_inventory.py b/tests/test_inventory.py new file mode 100644 index 0000000..59ed77f --- /dev/null +++ b/tests/test_inventory.py @@ -0,0 +1,30 @@ +"""Tests for the M5 system inventory (render + dict round-trip; collect on real system).""" + +import unittest + +from rigdoctor.core import inventory +from rigdoctor.core.inventory import Section + + +class InventoryTests(unittest.TestCase): + def test_collect_returns_sections(self): + sections = inventory.collect() + self.assertTrue(sections) + titles = {s.title for s in sections} + self.assertIn("System", titles) + self.assertIn("CPU", titles) + + def test_dict_round_trip(self): + sections = [Section("System", [("Kernel", "7.0.0"), ("Distro", "Ubuntu")])] + restored = inventory.from_dict(inventory.to_dict(sections)) + self.assertEqual(restored[0].title, "System") + self.assertEqual(restored[0].items, [("Kernel", "7.0.0"), ("Distro", "Ubuntu")]) + + def test_render_markdown(self): + md = inventory.render_markdown([Section("CPU", [("Model", "Test CPU")])]) + self.assertIn("## CPU", md) + self.assertIn("- **Model:** Test CPU", md) + + +if __name__ == "__main__": + unittest.main()