217 lines
8.4 KiB
Python
217 lines
8.4 KiB
Python
"""Guided troubleshooting panel: renders a diag.guides.GuideRunner."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from PySide6.QtCore import QSize, Qt
|
|
from PySide6.QtGui import QBrush, QColor, QIcon, QLinearGradient, QPainter, QPixmap
|
|
from PySide6.QtWidgets import (
|
|
QFrame,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QListWidget,
|
|
QListWidgetItem,
|
|
QProgressBar,
|
|
QPushButton,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from ..diag.engine import LED, DiagnosticsEngine
|
|
from ..diag.guides import GuideRunner, recommended
|
|
|
|
END_STYLE = { # end severity -> (border, background)
|
|
"ok": ("#2e9e44", "#e3f4e7"),
|
|
"info": ("#2e6da4", "#e3edf7"),
|
|
"warning": ("#c77c02", "#fdf0d6"),
|
|
"error": ("#d9534f", "#f9e0df"),
|
|
"critical": ("#b03a2e", "#f6d5d1"),
|
|
}
|
|
|
|
|
|
def _swatch(key: str, size: int = 22) -> QIcon:
|
|
"""Round LED-colour icon; CYCLING is drawn as a blue-dark-red gradient."""
|
|
pm = QPixmap(size, size)
|
|
pm.fill(QColor(0, 0, 0, 0))
|
|
p = QPainter(pm)
|
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
if key == "CYCLING":
|
|
g = QLinearGradient(0, 0, size, 0)
|
|
for stop, c in ((0.0, "#2f6fd6"), (0.5, "#444444"), (1.0, "#d64541")):
|
|
g.setColorAt(stop, QColor(c))
|
|
p.setBrush(QBrush(g))
|
|
else:
|
|
p.setBrush(QColor(LED.get(key, ("", "#888888"))[1]))
|
|
p.setPen(QColor("#777777"))
|
|
p.drawEllipse(1, 1, size - 2, size - 2)
|
|
p.end()
|
|
return QIcon(pm)
|
|
|
|
|
|
def _esc(s: str) -> str:
|
|
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
|
|
|
|
class GuidePanel(QWidget):
|
|
def __init__(self, runner: GuideRunner, engine: DiagnosticsEngine, parent: QWidget | None = None):
|
|
super().__init__(parent)
|
|
self.runner, self.engine = runner, engine
|
|
self._shown_key: tuple | None = None
|
|
|
|
# --- picker (no guide running)
|
|
self.picker = QWidget()
|
|
pl = QVBoxLayout(self.picker)
|
|
pl.setContentsMargins(0, 0, 0, 0)
|
|
pl.addWidget(QLabel("<b>Recommended checks for this box</b> (based on the log):"))
|
|
self.guide_list = QListWidget()
|
|
self.guide_list.itemDoubleClicked.connect(lambda it: self._start(it.data(Qt.ItemDataRole.UserRole)))
|
|
pl.addWidget(self.guide_list, 1)
|
|
self.start_btn = QPushButton("Start selected check")
|
|
self.start_btn.clicked.connect(self._start_selected)
|
|
pl.addWidget(self.start_btn)
|
|
|
|
# --- running guide
|
|
self.card = QFrame()
|
|
self.card.setFrameShape(QFrame.Shape.StyledPanel)
|
|
cl = QVBoxLayout(self.card)
|
|
self.guide_title = QLabel()
|
|
self.guide_title.setStyleSheet("color:#666")
|
|
self.step_text = QLabel(wordWrap=True)
|
|
f = self.step_text.font()
|
|
f.setPointSize(f.pointSize() + 2)
|
|
self.step_text.setFont(f)
|
|
self.step_text.setTextFormat(Qt.TextFormat.RichText)
|
|
self.options = QVBoxLayout()
|
|
self.wait_bar = QProgressBar()
|
|
self.wait_bar.setTextVisible(False)
|
|
self.wait_label = QLabel()
|
|
self.note = QLabel(wordWrap=True)
|
|
self.note.setStyleSheet("color:#b03a2e")
|
|
self.continue_btn = QPushButton("Continue")
|
|
self.continue_btn.clicked.connect(self._proceed)
|
|
self.skip_btn = QPushButton("Skip waiting / it did not work")
|
|
self.skip_btn.clicked.connect(self._proceed)
|
|
self.end_box = QLabel(wordWrap=True)
|
|
self.end_box.setTextFormat(Qt.TextFormat.RichText)
|
|
self.end_box.setMargin(10)
|
|
for w in (self.guide_title, self.step_text):
|
|
cl.addWidget(w)
|
|
cl.addLayout(self.options)
|
|
for w in (self.wait_label, self.wait_bar, self.note, self.continue_btn, self.skip_btn, self.end_box):
|
|
cl.addWidget(w)
|
|
cl.addStretch(1)
|
|
nav = QHBoxLayout()
|
|
self.back_btn = QPushButton("← Back")
|
|
self.back_btn.clicked.connect(self._back)
|
|
self.done_btn = QPushButton("Back to checks list")
|
|
self.done_btn.clicked.connect(self._cancel)
|
|
nav.addWidget(self.back_btn)
|
|
nav.addStretch(1)
|
|
nav.addWidget(self.done_btn)
|
|
cl.addLayout(nav)
|
|
|
|
lay = QVBoxLayout(self)
|
|
lay.setContentsMargins(4, 0, 0, 0)
|
|
lay.addWidget(self.picker, 1)
|
|
lay.addWidget(self.card, 1)
|
|
self.refresh()
|
|
|
|
# ------------------------------------------------------------ actions
|
|
def _start_selected(self) -> None:
|
|
it = self.guide_list.currentItem() or self.guide_list.item(0)
|
|
if it:
|
|
self._start(it.data(Qt.ItemDataRole.UserRole))
|
|
|
|
def _start(self, guide_id: str) -> None:
|
|
self.runner.start(guide_id)
|
|
self.refresh()
|
|
|
|
def _answer(self, key: str) -> None:
|
|
self.runner.answer(key)
|
|
self.refresh()
|
|
|
|
def _proceed(self) -> None:
|
|
self.runner.proceed()
|
|
self.refresh()
|
|
|
|
def _back(self) -> None:
|
|
self.runner.back()
|
|
self.refresh()
|
|
|
|
def _cancel(self) -> None:
|
|
self.runner.cancel()
|
|
self.refresh()
|
|
|
|
# ------------------------------------------------------------- render
|
|
def refresh(self) -> None:
|
|
"""Called after every interaction and periodically (timeouts, countdown)."""
|
|
self.runner.tick()
|
|
v = self.runner.view()
|
|
running = v.guide is not None
|
|
self.picker.setVisible(not running)
|
|
self.card.setVisible(running)
|
|
if not running:
|
|
self._fill_picker()
|
|
self._shown_key = None
|
|
return
|
|
key = (v.guide.id, v.step.id if v.step else None, v.end.id if v.end else None)
|
|
if key != self._shown_key:
|
|
self._shown_key = key
|
|
self._render_step(v)
|
|
if v.waiting:
|
|
total = v.step.timeout_s
|
|
self.wait_bar.setMaximum(int(total))
|
|
self.wait_bar.setValue(int(total - v.remaining_s))
|
|
m, s = divmod(int(v.remaining_s), 60)
|
|
self.wait_label.setText(f"Watching the live log... {m}:{s:02d} left")
|
|
self.note.setText(v.note)
|
|
self.note.setVisible(bool(v.note))
|
|
self.back_btn.setEnabled(v.can_back)
|
|
|
|
def _fill_picker(self) -> None:
|
|
wanted = [(g.id, g.title, g.summary) for g in recommended(self.runner.guides, self.engine)]
|
|
current = [(self.guide_list.item(i).data(Qt.ItemDataRole.UserRole)) for i in range(self.guide_list.count())]
|
|
if [w[0] for w in wanted] == current:
|
|
return
|
|
self.guide_list.clear()
|
|
for gid, title, summary in wanted:
|
|
it = QListWidgetItem(f"{title}\n {summary}")
|
|
it.setData(Qt.ItemDataRole.UserRole, gid)
|
|
self.guide_list.addItem(it)
|
|
self.guide_list.setCurrentRow(0)
|
|
|
|
def _render_step(self, v) -> None:
|
|
self.guide_title.setText(v.guide.title)
|
|
while self.options.count():
|
|
w = self.options.takeAt(0).widget()
|
|
if w:
|
|
# Detach now; deleteLater alone leaves it visible until the
|
|
# event loop processes the deferred delete.
|
|
w.hide()
|
|
w.setParent(None)
|
|
w.deleteLater()
|
|
is_end = v.end is not None
|
|
self.step_text.setVisible(not is_end)
|
|
self.step_text.setText(_esc(v.text))
|
|
for opt_key, label in v.options:
|
|
b = QPushButton(label)
|
|
b.setMinimumHeight(34)
|
|
if v.step and v.step.type == "led":
|
|
b.setIcon(_swatch(opt_key))
|
|
b.setIconSize(QSize(22, 22))
|
|
b.setStyleSheet("QPushButton { text-align:left; padding-left:10px; }")
|
|
b.clicked.connect(lambda _=False, k=opt_key: self._answer(k))
|
|
self.options.addWidget(b)
|
|
step_type = v.step.type if v.step else None
|
|
self.continue_btn.setVisible(step_type == "info")
|
|
self.skip_btn.setVisible(step_type == "wait")
|
|
self.wait_bar.setVisible(step_type == "wait")
|
|
self.wait_label.setVisible(step_type == "wait")
|
|
self.end_box.setVisible(is_end)
|
|
if is_end:
|
|
e = v.end
|
|
border, bg = END_STYLE.get(e.severity, END_STYLE["info"])
|
|
acts = "".join(f"<li>{_esc(a)}</li>" for a in e.actions)
|
|
self.end_box.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
|
|
self.end_box.setText(f"<h3 style='margin:0'>{_esc(e.title)}</h3><p>{_esc(e.text)}</p>"
|
|
+ (f"<ol>{acts}</ol>" if acts else ""))
|