Skip to content

Commit 7cd90ff

Browse files
committed
Add timeline and failure thumbnail to run history tab
The run history tab gains a Gantt-style strip showing every recent run on a horizontal time axis (status drives bar colour) and a side preview panel that surfaces the failure screenshot already captured by the artifact manager. Selection syncs both ways between the table and the strip so users can spot patterns by shape, then drill in by click.
1 parent 31037b3 commit 7cd90ff

6 files changed

Lines changed: 268 additions & 6 deletions

File tree

je_auto_control/gui/language_wrapper/english.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,10 @@
659659
"rh_open_artifact": "Open artifact",
660660
"rh_no_artifact": "Selected run has no artifact.",
661661
"rh_artifact_missing": "Artifact file no longer exists.",
662+
"rh_timeline_heading": "Timeline (oldest left → newest right)",
663+
"rh_preview_heading": "Preview",
664+
"rh_preview_empty": "Select a run to preview.",
665+
"rh_preview_no_artifact": "No screenshot for this run.",
662666

663667
# Profiler tab
664668
"prof_enable": "Enable profiler",

je_auto_control/gui/language_wrapper/japanese.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,10 @@
657657
"rh_open_artifact": "スクリーンショットを開く",
658658
"rh_no_artifact": "選択した実行にスクリーンショットはありません。",
659659
"rh_artifact_missing": "スクリーンショットファイルが存在しません。",
660+
"rh_timeline_heading": "タイムライン(左:古い → 右:新しい)",
661+
"rh_preview_heading": "プレビュー",
662+
"rh_preview_empty": "実行を選択するとプレビューが表示されます。",
663+
"rh_preview_no_artifact": "この実行のスクリーンショットはありません。",
660664

661665
# Profiler tab
662666
"prof_enable": "プロファイラを有効化",

je_auto_control/gui/language_wrapper/simplified_chinese.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,10 @@
647647
"rh_open_artifact": "打开截图",
648648
"rh_no_artifact": "所选记录没有截图。",
649649
"rh_artifact_missing": "截图文件已不存在。",
650+
"rh_timeline_heading": "时间轴(左旧右新)",
651+
"rh_preview_heading": "预览",
652+
"rh_preview_empty": "请选择一条记录预览。",
653+
"rh_preview_no_artifact": "此次执行没有截图。",
650654

651655
# Profiler tab
652656
"prof_enable": "启用性能分析",

je_auto_control/gui/language_wrapper/traditional_chinese.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,10 @@
648648
"rh_open_artifact": "開啟截圖",
649649
"rh_no_artifact": "所選紀錄沒有截圖。",
650650
"rh_artifact_missing": "截圖檔案已不存在。",
651+
"rh_timeline_heading": "時間軸(左舊右新)",
652+
"rh_preview_heading": "預覽",
653+
"rh_preview_empty": "請選擇一筆紀錄以預覽。",
654+
"rh_preview_no_artifact": "此次執行沒有截圖。",
651655

652656
# Profiler tab
653657
"prof_enable": "啟用效能分析",

je_auto_control/gui/run_history_tab.py

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
11
"""Run History tab: browse past scheduler / trigger / hotkey fires."""
22
import datetime as _dt
33
from pathlib import Path
4-
from typing import Optional
4+
from typing import List, Optional
55

66
from PySide6.QtCore import QTimer, Qt, QUrl
7-
from PySide6.QtGui import QDesktopServices
7+
from PySide6.QtGui import QDesktopServices, QPixmap
88
from PySide6.QtWidgets import (
9-
QAbstractItemView, QComboBox, QHBoxLayout, QHeaderView, QLabel,
10-
QMessageBox, QPushButton, QTableWidget, QTableWidgetItem,
9+
QAbstractItemView, QComboBox, QFrame, QHBoxLayout, QHeaderView, QLabel,
10+
QMessageBox, QPushButton, QSplitter, QTableWidget, QTableWidgetItem,
1111
QVBoxLayout, QWidget,
1212
)
1313

1414
from je_auto_control.gui._i18n_helpers import TranslatableMixin
1515
from je_auto_control.gui.language_wrapper.multi_language_wrapper import (
1616
language_wrapper,
1717
)
18+
from je_auto_control.gui.run_history_timeline import RunHistoryTimeline
1819
from je_auto_control.utils.run_history.history_store import (
1920
SOURCE_HOTKEY, SOURCE_MANUAL, SOURCE_REST, SOURCE_SCHEDULER,
20-
SOURCE_TRIGGER, STATUS_ERROR, STATUS_OK, STATUS_RUNNING,
21+
SOURCE_TRIGGER, STATUS_ERROR, STATUS_OK, STATUS_RUNNING, RunRecord,
2122
default_history_store,
2223
)
2324

@@ -63,6 +64,7 @@ class RunHistoryTab(TranslatableMixin, QWidget):
6364
def __init__(self, parent: Optional[QWidget] = None) -> None:
6465
super().__init__(parent)
6566
self._tr_init()
67+
self._records: List[RunRecord] = []
6668
self._filter = QComboBox()
6769
self._populate_filter()
6870
self._filter.currentIndexChanged.connect(self._refresh)
@@ -75,6 +77,17 @@ def __init__(self, parent: Optional[QWidget] = None) -> None:
7577
header.setSectionResizeMode(QHeaderView.Interactive)
7678
header.setStretchLastSection(True)
7779
self._count_label = QLabel()
80+
self._timeline = RunHistoryTimeline()
81+
self._timeline.run_clicked.connect(self._on_timeline_clicked)
82+
self._thumb_label = QLabel()
83+
self._thumb_label.setAlignment(Qt.AlignCenter)
84+
self._thumb_label.setMinimumSize(220, 160)
85+
self._thumb_label.setFrameShape(QFrame.StyledPanel)
86+
self._thumb_label.setScaledContents(False)
87+
self._thumb_caption = QLabel()
88+
self._thumb_caption.setWordWrap(True)
89+
self._thumb_caption.setAlignment(Qt.AlignTop)
90+
self._thumb_caption.setTextInteractionFlags(Qt.TextSelectableByMouse)
7891
self._timer = QTimer(self)
7992
self._timer.setInterval(_REFRESH_INTERVAL_MS)
8093
self._timer.timeout.connect(self._refresh)
@@ -89,6 +102,10 @@ def retranslate(self) -> None:
89102
self._repopulate_filter_labels()
90103
self._refresh()
91104

105+
def resizeEvent(self, event) -> None:
106+
super().resizeEvent(event)
107+
self._refresh_preview()
108+
92109
def _populate_filter(self) -> None:
93110
self._filter.blockSignals(True)
94111
for label_key, source_value in _SOURCES:
@@ -124,8 +141,27 @@ def _build_layout(self) -> None:
124141
clear_btn.clicked.connect(self._on_clear)
125142
top.addWidget(clear_btn)
126143
root.addLayout(top)
127-
root.addWidget(self._table, stretch=1)
144+
145+
self._tr(QLabel(), "rh_timeline_heading")
146+
timeline_label = self._tr(QLabel(), "rh_timeline_heading")
147+
root.addWidget(timeline_label)
148+
root.addWidget(self._timeline)
149+
150+
splitter = QSplitter(Qt.Horizontal)
151+
splitter.addWidget(self._table)
152+
preview = QWidget()
153+
preview_layout = QVBoxLayout(preview)
154+
preview_layout.setContentsMargins(0, 0, 0, 0)
155+
preview_layout.addWidget(self._tr(QLabel(), "rh_preview_heading"))
156+
preview_layout.addWidget(self._thumb_label, stretch=1)
157+
preview_layout.addWidget(self._thumb_caption)
158+
splitter.addWidget(preview)
159+
splitter.setStretchFactor(0, 3)
160+
splitter.setStretchFactor(1, 1)
161+
root.addWidget(splitter, stretch=1)
162+
128163
self._table.cellDoubleClicked.connect(self._on_cell_double_clicked)
164+
self._table.itemSelectionChanged.connect(self._on_selection_changed)
129165
open_row = QHBoxLayout()
130166
self._open_artifact_btn = self._tr(QPushButton(), "rh_open_artifact")
131167
self._open_artifact_btn.clicked.connect(self._open_selected_artifact)
@@ -149,12 +185,15 @@ def _refresh(self) -> None:
149185
runs = default_history_store.list_runs(limit=500, source_type=source)
150186
except ValueError:
151187
runs = []
188+
self._records = runs
152189
self._table.setRowCount(len(runs))
153190
for row, record in enumerate(runs):
154191
self._set_row(row, record)
155192
self._count_label.setText(
156193
_t("rh_count_label").replace("{n}", str(len(runs))),
157194
)
195+
self._timeline.set_records(runs)
196+
self._refresh_preview()
158197

159198
def _set_row(self, row: int, record) -> None:
160199
status_key = _STATUS_LABEL_KEYS.get(record.status, record.status)
@@ -176,6 +215,54 @@ def _set_row(self, row: int, record) -> None:
176215
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
177216
self._table.setItem(row, col, item)
178217

218+
def _selected_record(self) -> Optional[RunRecord]:
219+
row = self._table.currentRow()
220+
if row < 0 or row >= len(self._records):
221+
return None
222+
return self._records[row]
223+
224+
def _on_selection_changed(self) -> None:
225+
record = self._selected_record()
226+
self._timeline.set_highlighted(record.id if record is not None else None)
227+
self._refresh_preview()
228+
229+
def _on_timeline_clicked(self, run_id: int) -> None:
230+
for row, record in enumerate(self._records):
231+
if record.id == run_id:
232+
self._table.selectRow(row)
233+
return
234+
235+
def _refresh_preview(self) -> None:
236+
record = self._selected_record()
237+
if record is None:
238+
self._thumb_label.clear()
239+
self._thumb_label.setText(_t("rh_preview_empty"))
240+
self._thumb_caption.setText("")
241+
return
242+
path = record.artifact_path
243+
if not path:
244+
self._thumb_label.clear()
245+
self._thumb_label.setText(_t("rh_preview_no_artifact"))
246+
else:
247+
pixmap = QPixmap(path)
248+
if pixmap.isNull():
249+
self._thumb_label.clear()
250+
self._thumb_label.setText(_t("rh_artifact_missing"))
251+
else:
252+
self._thumb_label.setPixmap(pixmap.scaled(
253+
self._thumb_label.size(), Qt.KeepAspectRatio,
254+
Qt.SmoothTransformation,
255+
))
256+
caption = (
257+
f"#{record.id}{record.source_type}/{record.source_id}\n"
258+
f"{record.script_path}\n"
259+
f"{_format_time(record.started_at)} • "
260+
f"{_format_duration(record.duration_seconds)}"
261+
)
262+
if record.error_text:
263+
caption += f"\n{record.error_text}"
264+
self._thumb_caption.setText(caption)
265+
179266
def _selected_artifact_path(self) -> Optional[str]:
180267
row = self._table.currentRow()
181268
if row < 0:
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""Custom timeline widget for the Run History tab.
2+
3+
Renders one colored bar per run on a horizontal time axis (newest on the
4+
right). Status drives the bar colour (green = ok, red = error, amber =
5+
still running). Clicking a bar emits ``run_clicked`` so the host tab can
6+
sync the row selection / thumbnail preview.
7+
8+
Pure :mod:`PySide6` — the headless run history store has zero Qt deps.
9+
"""
10+
from dataclasses import dataclass
11+
from typing import List, Optional, Sequence, Tuple
12+
13+
from PySide6.QtCore import QRectF, Qt, Signal
14+
from PySide6.QtGui import QColor, QFont, QFontMetrics, QMouseEvent, QPainter
15+
from PySide6.QtWidgets import QSizePolicy, QWidget
16+
17+
from je_auto_control.utils.run_history.history_store import (
18+
STATUS_ERROR, STATUS_OK, STATUS_RUNNING, RunRecord,
19+
)
20+
21+
_STATUS_COLOURS = {
22+
STATUS_OK: QColor("#4caf50"),
23+
STATUS_ERROR: QColor("#e53935"),
24+
STATUS_RUNNING: QColor("#ffb300"),
25+
}
26+
_DEFAULT_COLOUR = QColor("#9e9e9e")
27+
_GUTTER = 6
28+
_MIN_BAR_PX = 4
29+
_BAR_HEIGHT_FRACTION = 0.55
30+
31+
32+
@dataclass
33+
class _Bar:
34+
record: RunRecord
35+
rect: QRectF
36+
37+
38+
class RunHistoryTimeline(QWidget):
39+
"""Horizontal Gantt-style strip of run records."""
40+
41+
run_clicked = Signal(int)
42+
43+
def __init__(self, parent: Optional[QWidget] = None) -> None:
44+
super().__init__(parent)
45+
self.setMinimumHeight(96)
46+
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
47+
self._records: List[RunRecord] = []
48+
self._bars: List[_Bar] = []
49+
self._range: Tuple[float, float] = (0.0, 0.0)
50+
self._highlight_id: Optional[int] = None
51+
self.setMouseTracking(True)
52+
53+
def set_records(self, records: Sequence[RunRecord]) -> None:
54+
"""Replace the displayed records and trigger a repaint."""
55+
self._records = list(records)
56+
self._range = self._compute_range(self._records)
57+
self.update()
58+
59+
def set_highlighted(self, run_id: Optional[int]) -> None:
60+
"""Visually mark a single run id (called from external selection)."""
61+
self._highlight_id = run_id
62+
self.update()
63+
64+
def paintEvent(self, event) -> None:
65+
del event
66+
painter = QPainter(self)
67+
try:
68+
painter.setRenderHint(QPainter.Antialiasing, True)
69+
painter.fillRect(self.rect(), self.palette().window())
70+
self._bars = self._layout_bars()
71+
self._draw_axis(painter)
72+
for bar in self._bars:
73+
self._draw_bar(painter, bar)
74+
finally:
75+
painter.end()
76+
77+
def mousePressEvent(self, event: QMouseEvent) -> None:
78+
if event.button() != Qt.LeftButton:
79+
super().mousePressEvent(event)
80+
return
81+
for bar in self._bars:
82+
if bar.rect.contains(event.position()):
83+
self._highlight_id = bar.record.id
84+
self.update()
85+
self.run_clicked.emit(bar.record.id)
86+
return
87+
super().mousePressEvent(event)
88+
89+
@staticmethod
90+
def _compute_range(records: Sequence[RunRecord]) -> Tuple[float, float]:
91+
if not records:
92+
return (0.0, 0.0)
93+
starts = [r.started_at for r in records]
94+
ends = []
95+
for r in records:
96+
if r.finished_at is not None:
97+
ends.append(r.finished_at)
98+
else:
99+
ends.append(r.started_at + 0.001)
100+
lo = min(starts)
101+
hi = max(ends)
102+
if hi <= lo:
103+
hi = lo + 1.0
104+
return (lo, hi)
105+
106+
def _layout_bars(self) -> List[_Bar]:
107+
if not self._records:
108+
return []
109+
lo, hi = self._range
110+
span = max(hi - lo, 1e-6)
111+
usable_w = max(1, self.width() - 2 * _GUTTER)
112+
bar_height = max(8, int(self.height() * _BAR_HEIGHT_FRACTION))
113+
y = (self.height() - bar_height) // 2
114+
bars: List[_Bar] = []
115+
for record in self._records:
116+
start_frac = (record.started_at - lo) / span
117+
end_at = record.finished_at if record.finished_at is not None \
118+
else min(hi, record.started_at + 0.001)
119+
end_frac = (end_at - lo) / span
120+
x = _GUTTER + int(start_frac * usable_w)
121+
width = max(_MIN_BAR_PX, int((end_frac - start_frac) * usable_w))
122+
rect = QRectF(x, y, width, bar_height)
123+
bars.append(_Bar(record=record, rect=rect))
124+
return bars
125+
126+
def _draw_axis(self, painter: QPainter) -> None:
127+
if not self._records:
128+
painter.setPen(self.palette().text().color())
129+
painter.drawText(self.rect(), Qt.AlignCenter, "no runs yet")
130+
return
131+
font = QFont(painter.font())
132+
font.setPointSize(max(7, font.pointSize() - 1))
133+
painter.setFont(font)
134+
metrics = QFontMetrics(font)
135+
lo, hi = self._range
136+
baseline_y = self.height() - max(2, metrics.descent() + 2)
137+
painter.setPen(QColor(120, 120, 120, 160))
138+
painter.drawLine(_GUTTER, baseline_y,
139+
self.width() - _GUTTER, baseline_y)
140+
painter.setPen(self.palette().text().color())
141+
from datetime import datetime
142+
try:
143+
left_label = datetime.fromtimestamp(lo).strftime("%H:%M:%S")
144+
right_label = datetime.fromtimestamp(hi).strftime("%H:%M:%S")
145+
except (OSError, ValueError, OverflowError):
146+
left_label, right_label = str(lo), str(hi)
147+
painter.drawText(_GUTTER, baseline_y - 2, left_label)
148+
right_w = metrics.horizontalAdvance(right_label)
149+
painter.drawText(self.width() - _GUTTER - right_w,
150+
baseline_y - 2, right_label)
151+
152+
def _draw_bar(self, painter: QPainter, bar: _Bar) -> None:
153+
colour = QColor(_STATUS_COLOURS.get(bar.record.status, _DEFAULT_COLOUR))
154+
if bar.record.id == self._highlight_id:
155+
painter.setPen(QColor(255, 255, 255, 220))
156+
else:
157+
painter.setPen(Qt.NoPen)
158+
painter.setBrush(colour)
159+
painter.drawRoundedRect(bar.rect, 3, 3)

0 commit comments

Comments
 (0)