cluster4npu/ui/dialogs/export_report_dialog.py
abin 55040733fe feat: implement Phase 1-4 performance visualization and device management
Phase 1 — Performance Benchmarking:
- PerformanceBenchmarker: sequential vs parallel benchmark with injectable runner
- PerformanceHistory: JSON-backed benchmark history with regression support
- PerformanceDashboard: real-time FPS/latency display widget
- BenchmarkDialog: one-click benchmark with 3-phase progress bar

Phase 2 — Device Management:
- DeviceManager: NPU dongle scan, assign/unassign, load balance recommendation
- DeviceManagementPanel: live device status cards with auto-refresh
- BottleneckAlert: dataclass for pipeline bottleneck detection

Phase 3 — Advanced Features:
- OptimizationEngine: 3 optimization rules (rebalance/adjust_queue/add_devices)
- TemplateManager: 3 built-in pipeline templates (YOLOv5, fire detection, dual-model)

Phase 4 — Report Export:
- ReportExporter: PDF (reportlab, optional) and CSV export
- ExportReportDialog: format selection + path picker UI

192 unit tests, all passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:32:05 +08:00

239 lines
8.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
ui/dialogs/export_report_dialog.py — 效能報告匯出對話框。
提供 ExportReportDialog(QDialog)讓使用者選擇報告格式PDF/CSV與儲存路徑
然後觸發 ReportExporter 執行匯出。
設計重點:
- _collect_report_data() 從各模組收集資料,每個來源都用 try/except 保護。
- 不在此模組執行實際 benchmark只使用 history 的最新一筆作為 parallel_result。
- chart_image_bytes 留 None截圖整合留未來
"""
from __future__ import annotations
from typing import TYPE_CHECKING, List, Optional
from PyQt5.QtWidgets import (
QDialog,
QFileDialog,
QHBoxLayout,
QLabel,
QPushButton,
QRadioButton,
QVBoxLayout,
QWidget,
QLineEdit,
QGroupBox,
QProgressBar,
)
from PyQt5.QtCore import Qt
from core.performance.report_exporter import DeviceSummary, ReportData, ReportExporter
if TYPE_CHECKING:
from core.performance.benchmarker import PerformanceBenchmarker
from core.performance.history import PerformanceHistory
class ExportReportDialog(QDialog):
"""
效能報告匯出對話框。
使用者可選擇格式PDF / CSV指定儲存路徑後按匯出
對話框會呼叫 ReportExporter 產出檔案並顯示結果。
"""
def __init__(
self,
parent: Optional[QWidget],
benchmarker, # PerformanceBenchmarker | None
history, # PerformanceHistory | None
device_manager, # DeviceManager | None
dashboard, # PerformanceDashboard | None
) -> None:
super().__init__(parent)
self._benchmarker = benchmarker
self._history = history
self._device_manager = device_manager
self._dashboard = dashboard
self._exporter = ReportExporter()
# 預設格式為 PDF
self._selected_format: str = "pdf"
self._setup_ui()
# ------------------------------------------------------------------
# UI 建立
# ------------------------------------------------------------------
def _setup_ui(self) -> None:
"""建立對話框 UI。"""
self.setWindowTitle("匯出效能報告")
main_layout = QVBoxLayout()
# 格式選擇
format_group = QGroupBox("匯出格式")
format_layout = QHBoxLayout()
self._pdf_radio = QRadioButton("PDF")
self._csv_radio = QRadioButton("CSV")
self._pdf_radio.setChecked(True)
self._pdf_radio.clicked.connect(lambda: self._set_format("pdf"))
self._csv_radio.clicked.connect(lambda: self._set_format("csv"))
format_layout.addWidget(self._pdf_radio)
format_layout.addWidget(self._csv_radio)
format_group.setLayout(format_layout)
main_layout.addWidget(format_group)
# 儲存路徑
path_layout = QHBoxLayout()
self._path_input = QLineEdit()
self._path_input.setPlaceholderText("儲存路徑…")
self._browse_btn = QPushButton("瀏覽")
self._browse_btn.clicked.connect(self._on_browse)
path_layout.addWidget(self._path_input)
path_layout.addWidget(self._browse_btn)
main_layout.addLayout(path_layout)
# 進度條
self._progress_bar = QProgressBar()
self._progress_bar.setVisible(False)
main_layout.addWidget(self._progress_bar)
# 匯出按鈕
self._export_btn = QPushButton("匯出")
self._export_btn.clicked.connect(self._on_export)
main_layout.addWidget(self._export_btn)
# 狀態標籤
self._status_label = QLabel("")
main_layout.addWidget(self._status_label)
self.setLayout(main_layout)
# ------------------------------------------------------------------
# 格式設定
# ------------------------------------------------------------------
def _set_format(self, fmt: str) -> None:
"""設定匯出格式('pdf''csv')。"""
self._selected_format = fmt
# ------------------------------------------------------------------
# 事件處理
# ------------------------------------------------------------------
def _on_browse(self) -> None:
"""開啟 QFileDialog 讓使用者選擇儲存路徑。"""
if self._selected_format == "pdf":
file_filter = "PDF 檔案 (*.pdf)"
default_suffix = ".pdf"
else:
file_filter = "CSV 檔案 (*.csv)"
default_suffix = ".csv"
path, _ = QFileDialog.getSaveFileName(
self,
"選擇儲存位置",
f"performance_report{default_suffix}",
file_filter,
)
if path:
self._path_input.setText(path)
def _on_export(self) -> None:
"""執行匯出:收集資料 -> 呼叫 ReportExporter。"""
output_path = self._path_input.text().strip()
if not output_path:
self._status_label.setText("請先指定儲存路徑。")
return
data = self._collect_report_data()
try:
if self._selected_format == "pdf":
result = self._exporter.export_pdf(data, output_path)
else:
result = self._exporter.export_csv(data, output_path)
self._status_label.setText(f"匯出成功:{result}")
except ImportError as e:
self._status_label.setText(f"匯出失敗(缺少函式庫):{e}")
except ValueError as e:
self._status_label.setText(f"匯出失敗(資料不足):{e}")
except Exception as e:
self._status_label.setText(f"匯出失敗:{e}")
# ------------------------------------------------------------------
# 資料收集
# ------------------------------------------------------------------
def _collect_report_data(self) -> ReportData:
"""
從各模組收集資料,組裝 ReportData。
每個來源都用 try/except 保護,失敗時使用 None / 空值。
不實際執行 benchmark只使用 history 的最新一筆作為 parallel_result。
"""
# 歷史記錄,同時從中取最近一筆 sequential / parallel 作為 result
history_records: list = []
seq_result = None
par_result = None
try:
records = self._history.get_history(limit=20) if self._history else []
history_records = list(records) if records else []
seq_result = next((r for r in history_records if r.mode == "sequential"), None)
par_result = next((r for r in history_records if r.mode == "parallel"), None)
except Exception:
history_records, seq_result, par_result = [], None, None
# 從 benchmarker.history 取最新一筆作為 parallel_resultfallback不執行新的 benchmark
if par_result is None:
try:
if self._benchmarker is not None:
hist = self._benchmarker.history
if hist:
par_result = hist[-1]
except Exception:
par_result = None
# 裝置資訊
devices: List[DeviceSummary] = []
try:
if self._device_manager is not None:
raw_devices = self._device_manager.scan_devices() or []
devices = self._convert_devices(raw_devices)
except Exception:
devices = []
return ReportData(
sequential_result=seq_result,
parallel_result=par_result,
speedup=None,
history_records=history_records,
devices=devices,
chart_image_bytes=None, # 截圖整合留未來
)
@staticmethod
def _convert_devices(raw_devices: list) -> List[DeviceSummary]:
"""
將 DeviceManager 回傳的裝置列表轉換為 DeviceSummary 列表。
若轉換失敗,略過該裝置。
"""
result: List[DeviceSummary] = []
for dev in raw_devices:
try:
result.append(DeviceSummary(
device_id=str(getattr(dev, "device_id", getattr(dev, "id", "unknown"))),
product_name=str(getattr(dev, "product_name", getattr(dev, "model", "unknown"))),
firmware_version=str(getattr(dev, "firmware_version", "unknown")),
is_active=bool(getattr(dev, "is_active", True)),
))
except Exception:
continue
return result