forked from masonhuang/cluster4npu
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>
239 lines
8.4 KiB
Python
239 lines
8.4 KiB
Python
"""
|
||
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_result(fallback,不執行新的 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
|