Skip to content

Commit 31037b3

Browse files
committed
Add per-action profiler with hot-spot view
Surfaces wall-clock duration per AC_* command so users can see which actions dominate a script's runtime. Profiling is opt-in (zero overhead when disabled) via AC_profiler_enable / disable / reset / stats / hot_spots, plus a Profiler GUI tab that polls the live aggregates.
1 parent 02aa892 commit 31037b3

11 files changed

Lines changed: 498 additions & 1 deletion

File tree

je_auto_control/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,10 @@
127127
ConfigBundleExporter, ConfigBundleImporter, ImportReport,
128128
export_config_bundle, import_config_bundle,
129129
)
130+
# Profiler (headless)
131+
from je_auto_control.utils.profiler import (
132+
ActionProfiler, ActionStats, default_profiler,
133+
)
130134
# Run history (headless)
131135
from je_auto_control.utils.run_history.history_store import (
132136
HistoryStore, RunRecord, default_history_store,
@@ -302,6 +306,8 @@ def start_autocontrol_gui(*args, **kwargs):
302306
"TriggerEngine", "default_trigger_engine",
303307
"ImageAppearsTrigger", "WindowAppearsTrigger",
304308
"PixelColorTrigger", "FilePathTrigger",
309+
# Profiler
310+
"ActionProfiler", "ActionStats", "default_profiler",
305311
# Run history
306312
"HistoryStore", "RunRecord", "default_history_store",
307313
# Accessibility

je_auto_control/gui/language_wrapper/english.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"tab_shell": "Shell Command",
3131
"tab_report": "Report",
3232
"tab_run_history": "Run History",
33+
"tab_profiler": "Profiler",
3334
"tab_accessibility": "Accessibility",
3435
"tab_vlm": "AI Locator",
3536
"tab_ocr_reader": "OCR Reader",
@@ -659,6 +660,23 @@
659660
"rh_no_artifact": "Selected run has no artifact.",
660661
"rh_artifact_missing": "Artifact file no longer exists.",
661662

663+
# Profiler tab
664+
"prof_enable": "Enable profiler",
665+
"prof_disable": "Disable profiler",
666+
"prof_reset": "Reset stats",
667+
"prof_refresh": "Refresh",
668+
"prof_running": "Profiler is recording.",
669+
"prof_paused": "Profiler is off — enable to record durations.",
670+
"prof_total_label": "{n} actions tracked",
671+
"prof_total_empty": "No samples yet.",
672+
"prof_col_name": "Action",
673+
"prof_col_calls": "Calls",
674+
"prof_col_total": "Total",
675+
"prof_col_avg": "Avg",
676+
"prof_col_min": "Min",
677+
"prof_col_max": "Max",
678+
"prof_col_share": "Share",
679+
662680
# Accessibility Tab
663681
"a11y_app_label": "App:",
664682
"a11y_app_placeholder": "e.g. Calculator",

je_auto_control/gui/language_wrapper/japanese.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"tab_shell": "シェル",
2929
"tab_report": "レポート",
3030
"tab_run_history": "実行履歴",
31+
"tab_profiler": "プロファイラ",
3132
"tab_accessibility": "アクセシビリティ",
3233
"tab_vlm": "AI ロケーター",
3334
"tab_ocr_reader": "OCR リーダー",
@@ -657,6 +658,23 @@
657658
"rh_no_artifact": "選択した実行にスクリーンショットはありません。",
658659
"rh_artifact_missing": "スクリーンショットファイルが存在しません。",
659660

661+
# Profiler tab
662+
"prof_enable": "プロファイラを有効化",
663+
"prof_disable": "プロファイラを無効化",
664+
"prof_reset": "統計をリセット",
665+
"prof_refresh": "更新",
666+
"prof_running": "プロファイラ計測中。",
667+
"prof_paused": "プロファイラは停止中です。",
668+
"prof_total_label": "{n} アクションを記録",
669+
"prof_total_empty": "サンプルがまだありません。",
670+
"prof_col_name": "アクション",
671+
"prof_col_calls": "回数",
672+
"prof_col_total": "合計",
673+
"prof_col_avg": "平均",
674+
"prof_col_min": "最小",
675+
"prof_col_max": "最大",
676+
"prof_col_share": "割合",
677+
660678
# Accessibility Tab
661679
"a11y_app_label": "アプリ:",
662680
"a11y_app_placeholder": "例: 電卓",

je_auto_control/gui/language_wrapper/simplified_chinese.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"tab_shell": "Shell 命令",
2121
"tab_report": "报告生成",
2222
"tab_run_history": "执行记录",
23+
"tab_profiler": "性能分析",
2324
"tab_accessibility": "无障碍树",
2425
"tab_vlm": "AI 定位",
2526
"tab_ocr_reader": "OCR 读取",
@@ -647,6 +648,23 @@
647648
"rh_no_artifact": "所选记录没有截图。",
648649
"rh_artifact_missing": "截图文件已不存在。",
649650

651+
# Profiler tab
652+
"prof_enable": "启用性能分析",
653+
"prof_disable": "停用性能分析",
654+
"prof_reset": "清除统计",
655+
"prof_refresh": "刷新",
656+
"prof_running": "性能分析中。",
657+
"prof_paused": "尚未启用性能分析。",
658+
"prof_total_label": "已追踪 {n} 个动作",
659+
"prof_total_empty": "暂无数据。",
660+
"prof_col_name": "动作",
661+
"prof_col_calls": "次数",
662+
"prof_col_total": "总时间",
663+
"prof_col_avg": "平均",
664+
"prof_col_min": "最短",
665+
"prof_col_max": "最长",
666+
"prof_col_share": "占比",
667+
650668
# Accessibility Tab
651669
"a11y_app_label": "应用:",
652670
"a11y_app_placeholder": "例如:计算器",

je_auto_control/gui/language_wrapper/traditional_chinese.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"tab_shell": "Shell 命令",
2222
"tab_report": "報告產生",
2323
"tab_run_history": "執行紀錄",
24+
"tab_profiler": "效能分析",
2425
"tab_accessibility": "無障礙樹",
2526
"tab_vlm": "AI 定位",
2627
"tab_ocr_reader": "OCR 讀取",
@@ -648,6 +649,23 @@
648649
"rh_no_artifact": "所選紀錄沒有截圖。",
649650
"rh_artifact_missing": "截圖檔案已不存在。",
650651

652+
# Profiler tab
653+
"prof_enable": "啟用效能分析",
654+
"prof_disable": "停用效能分析",
655+
"prof_reset": "清除統計",
656+
"prof_refresh": "重新整理",
657+
"prof_running": "效能分析中。",
658+
"prof_paused": "尚未啟用效能分析。",
659+
"prof_total_label": "已追蹤 {n} 個動作",
660+
"prof_total_empty": "尚無資料。",
661+
"prof_col_name": "動作",
662+
"prof_col_calls": "次數",
663+
"prof_col_total": "總時間",
664+
"prof_col_avg": "平均",
665+
"prof_col_min": "最短",
666+
"prof_col_max": "最長",
667+
"prof_col_share": "佔比",
668+
651669
# Accessibility Tab
652670
"a11y_app_label": "應用程式:",
653671
"a11y_app_placeholder": "例如:小算盤",

je_auto_control/gui/main_widget.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from je_auto_control.gui.llm_planner_tab import LLMPlannerTab
2020
from je_auto_control.gui.ocr_tab import OCRReaderTab
2121
from je_auto_control.gui.plugins_tab import PluginsTab
22+
from je_auto_control.gui.profiler_tab import ProfilerTab
2223
from je_auto_control.gui.admin_console_tab import AdminConsoleTab
2324
from je_auto_control.gui.audit_log_tab import AuditLogTab
2425
from je_auto_control.gui.diagnostics_tab import DiagnosticsTab
@@ -126,6 +127,8 @@ def __init__(self, parent=None):
126127
category="automation")
127128
self._add_tab("run_history", "tab_run_history", RunHistoryTab(),
128129
category="automation")
130+
self._add_tab("profiler", "tab_profiler", ProfilerTab(),
131+
category="automation")
129132
self._add_tab("window_manager", "tab_window_manager", WindowManagerTab(),
130133
category="system")
131134
self._add_tab("plugins", "tab_plugins", PluginsTab(),
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Profiler tab: visualise per-action wall-clock hot spots."""
2+
from typing import List, Optional
3+
4+
from PySide6.QtCore import QTimer, Qt
5+
from PySide6.QtWidgets import (
6+
QAbstractItemView, QHBoxLayout, QHeaderView, QLabel, QProgressBar,
7+
QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
8+
)
9+
10+
from je_auto_control.gui._i18n_helpers import TranslatableMixin
11+
from je_auto_control.gui.language_wrapper.multi_language_wrapper import (
12+
language_wrapper,
13+
)
14+
from je_auto_control.utils.profiler import default_profiler
15+
from je_auto_control.utils.profiler.profiler import ActionStats
16+
17+
_REFRESH_INTERVAL_MS = 1000
18+
_COLUMN_COUNT = 7
19+
20+
21+
def _t(key: str) -> str:
22+
return language_wrapper.translate(key, key)
23+
24+
25+
def _ms(seconds: float) -> str:
26+
if seconds <= 0:
27+
return "0 ms"
28+
if seconds < 1.0:
29+
return f"{seconds * 1000:.1f} ms"
30+
return f"{seconds:.3f} s"
31+
32+
33+
class ProfilerTab(TranslatableMixin, QWidget):
34+
"""Hot-spot table backed by :data:`default_profiler`."""
35+
36+
def __init__(self, parent: Optional[QWidget] = None) -> None:
37+
super().__init__(parent)
38+
self._tr_init()
39+
self._table = QTableWidget(0, _COLUMN_COUNT)
40+
self._table.setEditTriggers(QAbstractItemView.NoEditTriggers)
41+
self._table.setSelectionBehavior(QAbstractItemView.SelectRows)
42+
self._table.verticalHeader().setVisible(False)
43+
self._apply_table_headers()
44+
header = self._table.horizontalHeader()
45+
header.setSectionResizeMode(QHeaderView.Interactive)
46+
header.setStretchLastSection(True)
47+
self._totalbar = QProgressBar()
48+
self._totalbar.setRange(0, 100)
49+
self._totalbar.setTextVisible(True)
50+
self._status = QLabel()
51+
self._build_layout()
52+
self._timer = QTimer(self)
53+
self._timer.setInterval(_REFRESH_INTERVAL_MS)
54+
self._timer.timeout.connect(self._refresh)
55+
self._timer.start()
56+
self._refresh()
57+
58+
def retranslate(self) -> None:
59+
TranslatableMixin.retranslate(self)
60+
self._apply_table_headers()
61+
self._refresh()
62+
63+
def _apply_table_headers(self) -> None:
64+
self._table.setHorizontalHeaderLabels([
65+
_t("prof_col_name"), _t("prof_col_calls"),
66+
_t("prof_col_total"), _t("prof_col_avg"),
67+
_t("prof_col_min"), _t("prof_col_max"),
68+
_t("prof_col_share"),
69+
])
70+
71+
def _build_layout(self) -> None:
72+
root = QVBoxLayout(self)
73+
controls = QHBoxLayout()
74+
self._enable_btn = self._tr(QPushButton(), "prof_enable")
75+
self._enable_btn.clicked.connect(self._toggle_enable)
76+
controls.addWidget(self._enable_btn)
77+
reset_btn = self._tr(QPushButton(), "prof_reset")
78+
reset_btn.clicked.connect(self._on_reset)
79+
controls.addWidget(reset_btn)
80+
refresh_btn = self._tr(QPushButton(), "prof_refresh")
81+
refresh_btn.clicked.connect(self._refresh)
82+
controls.addWidget(refresh_btn)
83+
controls.addStretch()
84+
root.addLayout(controls)
85+
root.addWidget(self._table, stretch=1)
86+
root.addWidget(self._totalbar)
87+
root.addWidget(self._status)
88+
89+
def _toggle_enable(self) -> None:
90+
if default_profiler.enabled:
91+
default_profiler.disable()
92+
else:
93+
default_profiler.enable()
94+
self._refresh()
95+
96+
def _on_reset(self) -> None:
97+
default_profiler.reset()
98+
self._refresh()
99+
100+
def _refresh(self) -> None:
101+
rows: List[ActionStats] = default_profiler.stats()
102+
self._table.setRowCount(len(rows))
103+
total_seconds = sum(r.total_seconds for r in rows)
104+
for index, row in enumerate(rows):
105+
share = 0.0 if total_seconds <= 0 else row.total_seconds / total_seconds
106+
self._set_row(index, row, share)
107+
self._totalbar.setValue(min(100, int(total_seconds * 1000) % 101))
108+
if rows:
109+
self._totalbar.setFormat(
110+
_t("prof_total_label").replace("{n}", str(len(rows)))
111+
+ f" • {_ms(total_seconds)}",
112+
)
113+
else:
114+
self._totalbar.setFormat(_t("prof_total_empty"))
115+
running_text = _t("prof_running") if default_profiler.enabled \
116+
else _t("prof_paused")
117+
self._status.setText(running_text)
118+
self._enable_btn.setText(
119+
_t("prof_disable") if default_profiler.enabled
120+
else _t("prof_enable"),
121+
)
122+
123+
def _set_row(self, row: int, stats: ActionStats, share: float) -> None:
124+
values = (
125+
stats.name,
126+
str(stats.calls),
127+
_ms(stats.total_seconds),
128+
_ms(stats.average_seconds),
129+
_ms(stats.min_seconds),
130+
_ms(stats.max_seconds),
131+
f"{share * 100:.1f}%",
132+
)
133+
for col, text in enumerate(values):
134+
item = QTableWidgetItem(text)
135+
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
136+
self._table.setItem(row, col, item)

je_auto_control/utils/executor/action_executor.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
read_text_in_region as ocr_read_text_in_region,
4444
wait_for_text as ocr_wait_for_text,
4545
)
46+
from je_auto_control.utils.profiler.profiler import default_profiler
4647
from je_auto_control.utils.run_history.history_store import default_history_store
4748
from je_auto_control.utils.script_vars.interpolate import (
4849
interpolate_actions, interpolate_value,
@@ -428,6 +429,34 @@ def _ocr_find_regex_as_dicts(pattern: str,
428429
]
429430

430431

432+
def _profiler_stats_as_dicts(limit: Optional[int] = None) -> List[dict]:
433+
"""Executor adapter: dump profiler stats as JSON-friendly dicts."""
434+
rows = default_profiler.stats()
435+
if limit is not None:
436+
rows = rows[: max(0, int(limit))]
437+
return [row.to_dict() for row in rows]
438+
439+
440+
def _profiler_hot_spots_as_dicts(limit: int = 10) -> List[dict]:
441+
"""Executor adapter: top N actions by total time, as dicts."""
442+
return [row.to_dict() for row in default_profiler.hot_spots(int(limit))]
443+
444+
445+
def _profiler_enable() -> Dict[str, Any]:
446+
default_profiler.enable()
447+
return {"enabled": default_profiler.enabled}
448+
449+
450+
def _profiler_disable() -> Dict[str, Any]:
451+
default_profiler.disable()
452+
return {"enabled": default_profiler.enabled}
453+
454+
455+
def _profiler_reset() -> Dict[str, Any]:
456+
default_profiler.reset()
457+
return {"reset": True}
458+
459+
431460
def _history_list_as_dicts(limit: int = 100,
432461
source_type: Optional[str] = None) -> List[dict]:
433462
"""Executor adapter: list run history as plain dicts (JSON-friendly)."""
@@ -544,6 +573,13 @@ def __init__(self):
544573
"AC_history_list": _history_list_as_dicts,
545574
"AC_history_clear": default_history_store.clear,
546575

576+
# Profiler
577+
"AC_profiler_enable": _profiler_enable,
578+
"AC_profiler_disable": _profiler_disable,
579+
"AC_profiler_reset": _profiler_reset,
580+
"AC_profiler_stats": _profiler_stats_as_dicts,
581+
"AC_profiler_hot_spots": _profiler_hot_spots_as_dicts,
582+
547583
# Accessibility-tree widget location
548584
"AC_a11y_list": _a11y_list_as_dicts,
549585
"AC_a11y_find": _a11y_find_as_dict,
@@ -724,8 +760,10 @@ def _run_one_action(self, action: list, record: Dict[str, Any],
724760
raise_on_error: bool) -> None:
725761
"""Execute a single action, recording the result or raising."""
726762
key = "execute: " + str(action)
763+
action_name = action[0] if action and isinstance(action[0], str) else "<invalid>"
727764
try:
728-
record[key] = self._execute_event(action)
765+
with default_profiler.measure(action_name):
766+
record[key] = self._execute_event(action)
729767
except (LoopBreak, LoopContinue):
730768
raise
731769
except (AutoControlActionException, OSError, RuntimeError,
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Per-action profiler for the JSON action executor."""
2+
from je_auto_control.utils.profiler.profiler import (
3+
ActionProfiler, ActionStats, default_profiler,
4+
)
5+
6+
__all__ = ["ActionProfiler", "ActionStats", "default_profiler"]

0 commit comments

Comments
 (0)