- Test box: restart + start-up capture + online InfluxDB check (data newer than the restart), with a status strip; checklist and findings are the main view, live log / guided troubleshooting / box details are tabs, rarely used actions in a More menu - light palette on all systems (macOS dark mode made the colours unreadable) - new rule influx.invalid_value: -inf from a silent microphone makes InfluxDB reject every upload (HTTP 400); guide end names it - exclusive serial port on macOS/Linux: two tool windows no longer split the log between them - InfluxDB check: `since` filter, neutral wording when nothing new arrives Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
289 lines
12 KiB
Python
289 lines
12 KiB
Python
"""Diagnostics side panel: renders a diag.engine.Report.
|
||
|
||
Layout: verdict banner, the test checklist (start-up milestones plus the
|
||
online InfluxDB check), a findings tree (root causes; their consequences as
|
||
children; titles only) and a wrapped detail pane for the selected finding.
|
||
`details` is a separate widget for the host to place: expected LED (colour +
|
||
pattern + meaning when the box reports it), firmware and the box's own
|
||
self-report (KOTA lines).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from PySide6.QtCore import Qt
|
||
from PySide6.QtGui import QBrush, QColor
|
||
from PySide6.QtWidgets import (
|
||
QFrame,
|
||
QHBoxLayout,
|
||
QLabel,
|
||
QListWidget,
|
||
QListWidgetItem,
|
||
QPushButton,
|
||
QScrollArea,
|
||
QSizePolicy,
|
||
QSplitter,
|
||
QTextBrowser,
|
||
QTreeWidget,
|
||
QTreeWidgetItem,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from ..diag.engine import LED, Finding, Report, Severity
|
||
|
||
SEV_COLOR = {
|
||
Severity.CRITICAL: "#b03a2e",
|
||
Severity.ERROR: "#d9534f",
|
||
Severity.WARNING: "#c77c02",
|
||
Severity.INFO: "#2e6da4",
|
||
}
|
||
# verdict banner (background, text) by verdict severity; None = healthy
|
||
BANNER = {
|
||
Severity.CRITICAL: ("#f6d5d1", "#7b1e14"),
|
||
Severity.ERROR: ("#f9dedc", "#8a2019"),
|
||
Severity.WARNING: ("#fdebc8", "#6b4400"),
|
||
None: ("#d8f0dd", "#1b5e2a"),
|
||
}
|
||
_KEY = Qt.ItemDataRole.UserRole
|
||
BOX_STATE = {"ok": ("#1b7a33", "✔"), "warn": ("#c77c02", "!"), "bad": ("#b03a2e", "✖"), "info": ("#2e6da4", "i")}
|
||
# Extra checklist rows (e.g. the InfluxDB check): state -> (mark, colour)
|
||
ROW_STATE = {"ok": ("✔", "#1b7a33"), "bad": ("✖", "#b03a2e"), "warn": ("!", "#a86400"),
|
||
"running": ("…", "#2e6da4"), "pending": ("○", "#6b6b6b"), "off": ("–", "#6b6b6b"),
|
||
"note": ("", "#444444")}
|
||
|
||
|
||
def _heading(text: str) -> QLabel:
|
||
lbl = QLabel(text)
|
||
f = lbl.font()
|
||
f.setBold(True)
|
||
f.setPointSize(f.pointSize() + 1)
|
||
lbl.setFont(f)
|
||
lbl.setContentsMargins(0, 6, 0, 2)
|
||
return lbl
|
||
|
||
|
||
def _esc(s: str) -> str:
|
||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||
|
||
|
||
class DiagnosticsPanel(QWidget):
|
||
def __init__(self, parent: QWidget | None = None):
|
||
super().__init__(parent)
|
||
self.setMinimumWidth(380)
|
||
|
||
self.verdict = QLabel(wordWrap=True)
|
||
self.verdict.setMargin(10)
|
||
vf = self.verdict.font()
|
||
vf.setPointSize(vf.pointSize() + 2)
|
||
vf.setBold(True)
|
||
self.verdict.setFont(vf)
|
||
|
||
self.led_swatch = QLabel()
|
||
self.led_swatch.setFixedSize(22, 22)
|
||
self.led_text = QLabel(wordWrap=True)
|
||
led_row = QHBoxLayout()
|
||
led_row.addWidget(self.led_swatch, 0, Qt.AlignmentFlag.AlignTop)
|
||
led_row.addWidget(self.led_text, 1)
|
||
|
||
self.firmware = QLabel(wordWrap=True)
|
||
self.firmware.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||
|
||
self.box_title = _heading("Box self-report (KOTA lines)")
|
||
self.box = QLabel(wordWrap=True)
|
||
self.box.setTextFormat(Qt.TextFormat.RichText)
|
||
self.box.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||
|
||
self.milestones = QListWidget()
|
||
self.milestones.setFrameShape(QFrame.Shape.NoFrame)
|
||
self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection)
|
||
self.milestones.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||
self.milestones.setWordWrap(True)
|
||
self.milestones.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
self.milestones.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
||
self.milestones.setStyleSheet("QListWidget { background: transparent; } QListWidget::item { padding: 2px; }")
|
||
mf = self.milestones.font()
|
||
mf.setPointSize(mf.pointSize() + 1)
|
||
self.milestones.setFont(mf)
|
||
|
||
self.tree = QTreeWidget()
|
||
self.tree.setHeaderHidden(True)
|
||
self.tree.setIndentation(16)
|
||
self.tree.setTextElideMode(Qt.TextElideMode.ElideRight)
|
||
self.detail = QTextBrowser()
|
||
self.detail.setOpenExternalLinks(False)
|
||
findings = QSplitter(Qt.Orientation.Vertical)
|
||
findings.addWidget(self.tree)
|
||
findings.addWidget(self.detail)
|
||
findings.setSizes([220, 260])
|
||
|
||
lay = QVBoxLayout(self)
|
||
lay.setContentsMargins(4, 0, 4, 0)
|
||
lay.addWidget(self.verdict)
|
||
lay.addWidget(_heading("Test checklist (current boot)"))
|
||
lay.addWidget(self.milestones)
|
||
lay.addWidget(_heading("Findings - root causes first; click one for details"))
|
||
lay.addWidget(findings, 1)
|
||
|
||
# Box details: placed by the host (a separate tab).
|
||
inner = QWidget()
|
||
dl = QVBoxLayout(inner)
|
||
dl.addWidget(_heading("Status LED should be"))
|
||
dl.addLayout(led_row)
|
||
dl.addWidget(_heading("Firmware"))
|
||
dl.addWidget(self.firmware)
|
||
self.qr_btn = QPushButton("Matter pairing QR code...")
|
||
self.qr_btn.setToolTip("Show the box's Matter QR code on screen to scan with the phone")
|
||
dl.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft)
|
||
dl.addWidget(self.box_title)
|
||
dl.addWidget(self.box)
|
||
dl.addStretch(1)
|
||
self.details = QScrollArea()
|
||
self.details.setWidgetResizable(True)
|
||
self.details.setFrameShape(QFrame.Shape.NoFrame)
|
||
self.details.setWidget(inner)
|
||
|
||
self._findings: dict[str, Finding] = {}
|
||
self._selected: str | None = None
|
||
# key -> expanded, chosen by the user; survives the rebuild on refresh.
|
||
self._expanded: dict[str, bool] = {}
|
||
self.tree.currentItemChanged.connect(self._on_select)
|
||
self.tree.itemCollapsed.connect(lambda it: self._remember(it, False))
|
||
self.tree.itemExpanded.connect(lambda it: self._remember(it, True))
|
||
|
||
# ------------------------------------------------------------------
|
||
def show_report(self, r: Report, extra_rows: list[tuple[str, str]] = ()) -> None:
|
||
"""extra_rows: (state, text) appended to the checklist, state in ROW_STATE."""
|
||
bg, fg = BANNER.get(r.verdict_severity, BANNER[Severity.WARNING])
|
||
if r.lines == 0:
|
||
bg, fg = "#e6e6e6", "#333333"
|
||
self.verdict.setText(r.verdict)
|
||
self.verdict.setStyleSheet(f"QLabel {{ background:{bg}; color:{fg}; border-radius:6px; }}")
|
||
|
||
name, colour = LED[r.led]
|
||
self.led_swatch.setStyleSheet(
|
||
f"QLabel {{ background:{colour}; border:1px solid #777; border-radius:11px; }}")
|
||
ask = "" if r.led == "UNKNOWN" else " - <i>check it matches the box</i>"
|
||
pattern = f", {_esc(r.led_pattern)}" if r.led_pattern else ""
|
||
meaning = f" = {_esc(r.led_meaning)}" if r.led_meaning else ""
|
||
source = " <small style='color:#1b7a33'>(reported by the box)</small>" if r.led_exact else ""
|
||
reason = f"<br><small style='color:#666'>from: {_esc(r.led_reason)}</small>" if r.led_reason else ""
|
||
self.led_text.setText(f"<b>{name}{pattern}</b>{meaning}{source}{ask}{reason}")
|
||
|
||
fw = r.firmware
|
||
sd = {True: "reads Wi-Fi settings from the SD card",
|
||
False: "does not read Wi-Fi settings from the SD card",
|
||
None: "Wi-Fi-from-SD support unknown"}
|
||
self.firmware.setText(f"{_esc(fw.describe())}<br><small>{sd[fw.supports_sd_wifi]}</small>")
|
||
|
||
self.box_title.setVisible(bool(r.box))
|
||
self.box.setVisible(bool(r.box))
|
||
self.box.setText("<table cellspacing='0' cellpadding='1'>" + "".join(
|
||
f"<tr><td style='color:{BOX_STATE[st][0]}'><b>{BOX_STATE[st][1]}</b> </td>"
|
||
f"<td><b>{_esc(topic)}</b> </td><td>{_esc(text)}</td></tr>"
|
||
for topic, text, st in r.box) + "</table>")
|
||
|
||
self.milestones.clear()
|
||
for _, label, t in r.milestones:
|
||
done = t is not None
|
||
it = QListWidgetItem(("✔ " if done else "○ ") + label
|
||
+ (f" ({t / 1000:.1f} s)" if done else ""))
|
||
it.setForeground(QBrush(QColor("#1b7a33" if done else "#6b6b6b")))
|
||
self.milestones.addItem(it)
|
||
for state, text in extra_rows:
|
||
mark, colour = ROW_STATE[state]
|
||
it = QListWidgetItem(f"{mark} {text}" if mark else text)
|
||
it.setForeground(QBrush(QColor(colour)))
|
||
if state != "note":
|
||
f = it.font()
|
||
f.setBold(True)
|
||
it.setFont(f)
|
||
it.setToolTip(text)
|
||
self.milestones.addItem(it)
|
||
height = sum(max(self.milestones.sizeHintForRow(i), 16) for i in range(self.milestones.count()))
|
||
self.milestones.setFixedHeight(min(max(height, 16), 560) + 6)
|
||
|
||
self._fill_tree(r)
|
||
|
||
def _fill_tree(self, r: Report) -> None:
|
||
self.tree.blockSignals(True)
|
||
self.tree.clear()
|
||
self._findings = {}
|
||
if not r.roots:
|
||
it = QTreeWidgetItem(["No problems detected in the log so far."])
|
||
it.setForeground(0, QBrush(QColor("#1b7a33")))
|
||
self.tree.addTopLevelItem(it)
|
||
for rc in r.roots:
|
||
top = self._item(rc.finding, bold=True)
|
||
self.tree.addTopLevelItem(top)
|
||
for c in rc.consequences:
|
||
top.addChild(self._item(c, prefix="↳ "))
|
||
top.setExpanded(self._expanded.get(rc.finding.key, True))
|
||
for title, key, items in (("Seen in earlier boots only", "grp:earlier", r.earlier),
|
||
("Resolved during this session", "grp:resolved", r.resolved)):
|
||
if not items:
|
||
continue
|
||
grp = QTreeWidgetItem([f"{title} ({len(items)})"])
|
||
grp.setData(0, _KEY, key)
|
||
grp.setForeground(0, QBrush(QColor("#666666")))
|
||
self.tree.addTopLevelItem(grp)
|
||
for f in items:
|
||
grp.addChild(self._item(f, prefix="✔ " if key == "grp:resolved" else ""))
|
||
grp.setExpanded(self._expanded.get(key, False))
|
||
# Keep the selection across refreshes; default to the top root cause.
|
||
target = self._selected if self._selected in self._findings else (
|
||
r.roots[0].finding.key if r.roots else None)
|
||
self.tree.blockSignals(False)
|
||
self._select(target)
|
||
|
||
def _remember(self, item: QTreeWidgetItem, expanded: bool) -> None:
|
||
if key := item.data(0, _KEY):
|
||
self._expanded[key] = expanded
|
||
|
||
def _item(self, f: Finding, prefix: str = "", bold: bool = False) -> QTreeWidgetItem:
|
||
it = QTreeWidgetItem([f"{prefix}[{f.severity.label}] {f.title}"])
|
||
it.setData(0, _KEY, f.key)
|
||
it.setForeground(0, QBrush(QColor(SEV_COLOR.get(f.severity, "#333333"))))
|
||
it.setToolTip(0, f.title)
|
||
if bold:
|
||
font = it.font(0)
|
||
font.setBold(True)
|
||
it.setFont(0, font)
|
||
self._findings[f.key] = f
|
||
return it
|
||
|
||
def _select(self, key: str | None) -> None:
|
||
if key is None:
|
||
self.detail.setHtml("<p style='color:#666'>Nothing to show yet.</p>")
|
||
return
|
||
match = self.tree.findItems("*", Qt.MatchFlag.MatchWildcard | Qt.MatchFlag.MatchRecursive)
|
||
for it in match:
|
||
if it.data(0, _KEY) == key:
|
||
self.tree.setCurrentItem(it)
|
||
return
|
||
|
||
def _on_select(self, cur: QTreeWidgetItem | None, _prev) -> None:
|
||
key = cur.data(0, _KEY) if cur else None
|
||
f = self._findings.get(key or "")
|
||
if f is None:
|
||
return
|
||
self._selected = key
|
||
self.detail.setHtml(_detail_html(f))
|
||
|
||
|
||
def _detail_html(f: Finding) -> str:
|
||
colour = SEV_COLOR.get(f.severity, "#333")
|
||
actions = "".join(f"<li>{_esc(a)}</li>" for a in f.actions)
|
||
seen = _esc(f.seen_text())
|
||
evidence = "<br>".join(_esc(e) for e in f.evidence)
|
||
codes = "".join(f"<li><b>{_esc(eid)}</b>" + (f" - {_esc(a)}" if a else "") + "</li>"
|
||
for eid, a in f.error_ids.items())
|
||
return (
|
||
f"<h3 style='color:{colour}; margin:0'>{_esc(f.title)}</h3>"
|
||
f"<p><b>What it means:</b> {_esc(f.meaning)}</p>"
|
||
+ (f"<p><b>What to do:</b></p><ol>{actions}</ol>" if actions else "")
|
||
+ (f"<p><b>Firmware error codes</b> (error_ids.csv):</p><ul>{codes}</ul>" if codes else "")
|
||
+ f"<p style='color:#666'>{seen}</p>"
|
||
+ (f"<p style='color:#555; font-family:Consolas,Menlo,monospace; font-size:9pt'>{evidence}</p>"
|
||
if evidence else "")
|
||
)
|