Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f25ac939cc | |||
| b47006bc22 |
@@ -5,6 +5,19 @@ All notable changes to RigDoctor are recorded here. Format follows
|
|||||||
(`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
|
(`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
|
||||||
release tag (so the auto-updater, D18, can compare versions).
|
release tag (so the auto-updater, D18, can compare versions).
|
||||||
|
|
||||||
|
## [0.7.3] - 2026-05-21
|
||||||
|
### Fixed
|
||||||
|
- Shared terminal now has **scrollback** — large output (e.g. `ls -la`) can be scrolled up to
|
||||||
|
read; it keeps a history buffer and only auto-scrolls to the bottom when you're already there.
|
||||||
|
|
||||||
|
## [0.7.2] - 2026-05-21
|
||||||
|
### Changed
|
||||||
|
- Removed the GUI **Inventory** tab — use the CLI `rigdoctor inventory` instead. (Inventory is
|
||||||
|
still collected for the relay guest view, so a remote helper still sees the host's hardware.)
|
||||||
|
### Fixed
|
||||||
|
- Shared terminal caret now sits at the real cursor position (row **and** column) instead of
|
||||||
|
the start of the line.
|
||||||
|
|
||||||
## [0.7.1] - 2026-05-21
|
## [0.7.1] - 2026-05-21
|
||||||
### Fixed
|
### Fixed
|
||||||
- Shared terminal: a guest who joined **after** the host enabled the terminal stayed read-only.
|
- Shared terminal: a guest who joined **after** the host enabled the terminal stayed read-only.
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "rigdoctor"
|
name = "rigdoctor"
|
||||||
version = "0.7.1"
|
version = "0.7.3"
|
||||||
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
|
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
||||||
|
|
||||||
__version__ = "0.7.1"
|
__version__ = "0.7.3"
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
"""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
|
|
||||||
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._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)}")
|
|
||||||
@@ -29,7 +29,6 @@ from ..config import load_config
|
|||||||
from ..core import alerts, elevation, updates
|
from ..core import alerts, elevation, updates
|
||||||
from .dashboard import Dashboard
|
from .dashboard import Dashboard
|
||||||
from .health_page import HealthPage
|
from .health_page import HealthPage
|
||||||
from .inventory_page import InventoryPage
|
|
||||||
from .notifications_page import NotificationsPage
|
from .notifications_page import NotificationsPage
|
||||||
from .recorder_page import RecorderPage
|
from .recorder_page import RecorderPage
|
||||||
from .setup_page import SetupPage
|
from .setup_page import SetupPage
|
||||||
@@ -37,7 +36,7 @@ from .share_page import SharePage
|
|||||||
from .theme import ACCENT, GOOD, MUTED
|
from .theme import ACCENT, GOOD, MUTED
|
||||||
from .worker import SamplerWorker
|
from .worker import SamplerWorker
|
||||||
|
|
||||||
_NAV_ITEMS = ["Dashboard", "Logs", "Health", "Setup", "Inventory", "Notifications", "Share"]
|
_NAV_ITEMS = ["Dashboard", "Logs", "Health", "Setup", "Notifications", "Share"]
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
@@ -68,7 +67,6 @@ class MainWindow(QMainWindow):
|
|||||||
self.recorder_page = RecorderPage()
|
self.recorder_page = RecorderPage()
|
||||||
self.health_page = HealthPage()
|
self.health_page = HealthPage()
|
||||||
self.setup_page = SetupPage()
|
self.setup_page = SetupPage()
|
||||||
self.inventory_page = InventoryPage()
|
|
||||||
self.notifications_page = NotificationsPage()
|
self.notifications_page = NotificationsPage()
|
||||||
self.notifications_page.changed.connect(self._apply_alert_settings)
|
self.notifications_page.changed.connect(self._apply_alert_settings)
|
||||||
self.share_page = SharePage()
|
self.share_page = SharePage()
|
||||||
@@ -76,9 +74,8 @@ class MainWindow(QMainWindow):
|
|||||||
self._stack.addWidget(self.recorder_page) # 1 Logs
|
self._stack.addWidget(self.recorder_page) # 1 Logs
|
||||||
self._stack.addWidget(self.health_page) # 2 Health
|
self._stack.addWidget(self.health_page) # 2 Health
|
||||||
self._stack.addWidget(self.setup_page) # 3 Setup
|
self._stack.addWidget(self.setup_page) # 3 Setup
|
||||||
self._stack.addWidget(self.inventory_page) # 4 Inventory
|
self._stack.addWidget(self.notifications_page) # 4 Notifications
|
||||||
self._stack.addWidget(self.notifications_page) # 5 Notifications
|
self._stack.addWidget(self.share_page) # 5 Share
|
||||||
self._stack.addWidget(self.share_page) # 6 Share
|
|
||||||
content_layout.addWidget(self._stack)
|
content_layout.addWidget(self._stack)
|
||||||
|
|
||||||
layout.addWidget(self._build_sidebar())
|
layout.addWidget(self._build_sidebar())
|
||||||
@@ -228,9 +225,9 @@ class MainWindow(QMainWindow):
|
|||||||
self._elevated.emit()
|
self._elevated.emit()
|
||||||
|
|
||||||
def _on_elevated(self) -> None:
|
def _on_elevated(self) -> None:
|
||||||
# Re-run Health and Inventory now that root-only data (SMART/dmidecode) is available.
|
# 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`.)
|
||||||
self.health_page._run()
|
self.health_page._run()
|
||||||
self.inventory_page._run()
|
|
||||||
|
|
||||||
def _apply_alert_settings(self) -> None:
|
def _apply_alert_settings(self) -> None:
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class TerminalView(QPlainTextEdit):
|
|||||||
self.setUndoRedoEnabled(False)
|
self.setUndoRedoEnabled(False)
|
||||||
self.setMinimumHeight(260)
|
self.setMinimumHeight(260)
|
||||||
self._rows, self._cols = rows, cols
|
self._rows, self._cols = rows, cols
|
||||||
self._screen = pyte.Screen(cols, rows)
|
self._screen = pyte.HistoryScreen(cols, rows, history=1000, ratio=0.5)
|
||||||
self._stream = pyte.ByteStream(self._screen)
|
self._stream = pyte.ByteStream(self._screen)
|
||||||
|
|
||||||
def grid(self) -> tuple[int, int]:
|
def grid(self) -> tuple[int, int]:
|
||||||
@@ -38,14 +38,24 @@ class TerminalView(QPlainTextEdit):
|
|||||||
self._screen.reset()
|
self._screen.reset()
|
||||||
self._render()
|
self._render()
|
||||||
|
|
||||||
|
def _row_text(self, row) -> str:
|
||||||
|
return "".join(row[x].data for x in range(self._cols)).rstrip()
|
||||||
|
|
||||||
def _render(self) -> None:
|
def _render(self) -> None:
|
||||||
self.setPlainText("\n".join(self._screen.display))
|
bar = self.verticalScrollBar()
|
||||||
# Follow the terminal cursor so output (e.g. `ls -la`) stays in view.
|
at_bottom = bar.value() >= bar.maximum() - 2
|
||||||
cursor = self.textCursor()
|
prev = bar.value()
|
||||||
cursor.movePosition(QTextCursor.MoveOperation.Start)
|
history = [self._row_text(r) for r in self._screen.history.top] # scrollback
|
||||||
cursor.movePosition(QTextCursor.MoveOperation.Down, QTextCursor.MoveMode.MoveAnchor, self._screen.cursor.y)
|
self.setPlainText("\n".join(history + list(self._screen.display)))
|
||||||
self.setTextCursor(cursor)
|
if at_bottom: # follow output; place caret at the real (row, col)
|
||||||
self.ensureCursorVisible()
|
cursor = self.textCursor()
|
||||||
|
cursor.movePosition(QTextCursor.MoveOperation.Start)
|
||||||
|
cursor.movePosition(QTextCursor.MoveOperation.Down, QTextCursor.MoveMode.MoveAnchor, len(history) + self._screen.cursor.y)
|
||||||
|
cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.MoveAnchor, self._screen.cursor.x)
|
||||||
|
self.setTextCursor(cursor)
|
||||||
|
self.ensureCursorVisible()
|
||||||
|
else: # user scrolled up to read — keep their place
|
||||||
|
bar.setValue(prev)
|
||||||
|
|
||||||
def resizeEvent(self, event): # noqa: N802 (Qt override)
|
def resizeEvent(self, event): # noqa: N802 (Qt override)
|
||||||
super().resizeEvent(event)
|
super().resizeEvent(event)
|
||||||
|
|||||||
Reference in New Issue
Block a user