@@ -8372,6 +8518,7 @@ def get_local_ip():
if (name === 'security') { loadSecurityPage(); loadSecurityPosture(); }
if (name === 'channels') loadChannelsPage();
if (name === 'logs') { if (!logStream || logStream.readyState === EventSource.CLOSED) startLogStream(); loadLogs(); }
+ if (name === 'actions') loadQAHistory();
}
function exportUsageData() {
@@ -9451,6 +9598,99 @@ def get_local_ip():
}
}
+// ═══ QUICK ACTIONS ═══════════════════════════════════════════════════════════
+var _qaCurrentAction = null;
+
+function confirmAction(actionKey, title, body) {
+ _qaCurrentAction = actionKey;
+ document.getElementById('qa-confirm-title').textContent = title;
+ document.getElementById('qa-confirm-body').textContent = body;
+ var overlay = document.getElementById('qa-confirm-overlay');
+ overlay.style.display = 'flex';
+ document.getElementById('qa-confirm-ok').onclick = function() {
+ closeQAConfirm();
+ runQuickAction(actionKey);
+ };
+}
+
+function closeQAConfirm() {
+ var overlay = document.getElementById('qa-confirm-overlay');
+ if (overlay) overlay.style.display = 'none';
+ _qaCurrentAction = null;
+}
+
+async function runQuickAction(actionKey) {
+ var banner = document.getElementById('qa-result-banner');
+ if (banner) {
+ banner.style.display = 'block';
+ banner.style.background = 'rgba(96,165,250,0.1)';
+ banner.style.borderColor = 'rgba(96,165,250,0.3)';
+ banner.style.color = '#60a5fa';
+ banner.textContent = 'Running ' + actionKey + '...';
+ }
+ try {
+ var resp = await fetch('/api/actions/run', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({action: actionKey})
+ });
+ var data = await resp.json();
+ if (banner) {
+ if (data.ok) {
+ banner.style.background = 'rgba(34,197,94,0.1)';
+ banner.style.borderColor = 'rgba(34,197,94,0.3)';
+ banner.style.color = '#22c55e';
+ banner.textContent = '[ok] ' + (data.output || actionKey + ' completed') + ' (' + (data.duration_ms || 0) + 'ms)';
+ } else {
+ banner.style.background = 'rgba(239,68,68,0.1)';
+ banner.style.borderColor = 'rgba(239,68,68,0.3)';
+ banner.style.color = '#ef4444';
+ banner.textContent = '[error] ' + (data.output || data.error || 'Action failed');
+ }
+ }
+ loadQAHistory();
+ } catch(e) {
+ if (banner) {
+ banner.style.background = 'rgba(239,68,68,0.1)';
+ banner.style.borderColor = 'rgba(239,68,68,0.3)';
+ banner.style.color = '#ef4444';
+ banner.textContent = '[error] ' + e.message;
+ }
+ }
+}
+
+async function loadQAHistory() {
+ var el = document.getElementById('qa-history-list');
+ if (!el) return;
+ try {
+ var data = await fetchJsonWithTimeout('/api/actions/history', 5000);
+ var actions = data.actions || [];
+ if (!actions.length) {
+ el.textContent = 'No actions run yet.';
+ return;
+ }
+ var html = '
';
+ actions.slice().reverse().forEach(function(a) {
+ var color = a.ok ? '#22c55e' : '#ef4444';
+ var label = a.ok ? '[ok]' : '[error]';
+ html += '';
+ html += '| ' + label + ' | ';
+ html += '' + (a.action || '') + ' | ';
+ html += '' + (a.output || '').slice(0, 120) + ' | ';
+ html += '' + (a.duration_ms || 0) + 'ms | ';
+ html += '
';
+ });
+ html += '
';
+ el.innerHTML = html;
+ } catch(e) {
+ el.textContent = 'Could not load action history.';
+ }
+}
+
+// Load QA history when Actions tab is opened
+var _origSwitchTab = typeof switchTab === 'function' ? switchTab : null;
+// (patched below after switchTab is defined)
+
var _channelsRefreshTimer = null;
async function loadChannelsPage() {
try {
@@ -21229,6 +21469,146 @@ def api_heartbeat_ping():
return jsonify({'ok': True})
+@bp_health.route('/api/actions/run', methods=['POST'])
+def api_quick_action_run():
+ """Execute a Quick Action on the local OpenClaw / ClawMetry installation.
+
+ POST body: {"action": "restart-gateway"|"clear-cache"|"rotate-logs"|"health-check"}
+ Returns: {"ok": bool, "action": str, "output": str, "duration_ms": int}
+ """
+ try:
+ body = request.get_json(silent=True) or {}
+ except Exception:
+ body = {}
+ action = str(body.get('action', '')).strip()
+ allowed = {'restart-gateway', 'clear-cache', 'rotate-logs', 'health-check'}
+ if action not in allowed:
+ return jsonify({'ok': False, 'error': f'Unknown action: {action}', 'output': '', 'duration_ms': 0}), 400
+
+ t0 = time.time()
+ output = ''
+ ok = False
+
+ try:
+ if action == 'restart-gateway':
+ # Try openclaw / systemctl restart; fallback to SIGUSR1 on the process
+ cmds = [
+ ['openclaw', 'gateway', 'restart'],
+ ['systemctl', '--user', 'restart', 'openclaw'],
+ ['systemctl', 'restart', 'openclaw'],
+ ]
+ for cmd in cmds:
+ try:
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
+ if r.returncode == 0:
+ output = (r.stdout or r.stderr or 'Gateway restart command accepted').strip()
+ ok = True
+ break
+ output = (r.stderr or r.stdout or 'Command failed').strip()
+ except FileNotFoundError:
+ continue
+ except Exception as exc:
+ output = str(exc)
+ if not ok:
+ output = output or 'No supported restart mechanism found (openclaw / systemctl).'
+
+ elif action == 'clear-cache':
+ # Clear ClawMetry's in-process caches and any temp files
+ removed = []
+ cache_dirs = [
+ os.path.join(os.path.expanduser('~'), '.clawmetry', 'cache'),
+ '/tmp/clawmetry_cache',
+ ]
+ for d in cache_dirs:
+ if os.path.isdir(d):
+ import shutil as _shutil
+ _shutil.rmtree(d, ignore_errors=True)
+ removed.append(d)
+ # Clear OTEL in-memory store
+ try:
+ global _otel_store
+ _otel_store = {'tokens': [], 'cost': [], 'runs': [], 'messages': [], 'webhooks': [], 'dequeues': []}
+ except Exception:
+ pass
+ ok = True
+ output = 'Cache cleared: ' + (', '.join(removed) if removed else 'in-process caches reset')
+
+ elif action == 'rotate-logs':
+ # Send SIGUSR1 to processes named 'openclaw' / 'clawmetry', which conventionally triggers log rotation
+ rotated = []
+ import signal as _signal
+ for proc_name in ('openclaw', 'clawmetry'):
+ try:
+ r = subprocess.run(['pkill', '-USR1', proc_name], capture_output=True, timeout=5)
+ if r.returncode == 0:
+ rotated.append(proc_name)
+ except Exception:
+ pass
+ # Also attempt logrotate if available
+ try:
+ conf_path = '/etc/logrotate.d/openclaw'
+ if os.path.exists(conf_path) and shutil.which('logrotate'):
+ subprocess.run(['logrotate', '-f', conf_path], capture_output=True, timeout=10)
+ rotated.append('logrotate')
+ except Exception:
+ pass
+ ok = True
+ output = ('Log rotation signal sent to: ' + ', '.join(rotated)) if rotated else 'No running openclaw processes found to signal'
+
+ elif action == 'health-check':
+ # Collect a quick health snapshot
+ health_parts = []
+ cfg = _load_gw_config()
+ gw_url = cfg.get('url', 'http://127.0.0.1:18789')
+ token = cfg.get('token', '')
+ # Gateway ping
+ try:
+ req = _urllib_req.Request(
+ f"{gw_url.rstrip('/')}/api/overview",
+ headers={'Authorization': f'Bearer {token}'} if token else {},
+ )
+ with _urllib_req.urlopen(req, timeout=5) as resp:
+ health_parts.append(f'Gateway: OK ({resp.status})')
+ except Exception as exc:
+ health_parts.append(f'Gateway: UNREACHABLE ({exc})')
+ # Disk
+ try:
+ usage = shutil.disk_usage(os.path.expanduser('~'))
+ pct = 100 * usage.used // usage.total
+ health_parts.append(f'Disk: {pct}% used ({usage.free // 1_073_741_824}GB free)')
+ except Exception:
+ pass
+ # Session count
+ try:
+ sc = len(_get_sessions())
+ health_parts.append(f'Sessions: {sc}')
+ except Exception:
+ pass
+ ok = True
+ output = ' | '.join(health_parts) or 'Health check complete'
+
+ except Exception as exc:
+ output = f'Error: {exc}'
+ ok = False
+
+ duration_ms = int((time.time() - t0) * 1000)
+ return jsonify({'ok': ok, 'action': action, 'output': output, 'duration_ms': duration_ms})
+
+
+@bp_health.route('/api/actions/history')
+def api_quick_action_history():
+ """Return in-memory log of recent quick actions (last 50 entries)."""
+ return jsonify({'actions': list(_quick_action_log), 'total': len(_quick_action_log)})
+
+
+# In-process ring-buffer for quick action history (last 50)
+try:
+ from collections import deque as _deque
+ _quick_action_log = _deque(maxlen=50)
+except Exception:
+ _quick_action_log = []
+
+
@bp_health.route('/api/health-stream')
def api_health_stream():
"""SSE endpoint - auto-refresh health checks every 30 seconds."""
diff --git a/tests/test_api.py b/tests/test_api.py
index 0bc61e0..a7fa58f 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -471,3 +471,60 @@ def test_memory_analytics_files_have_status(self, api, base_url):
for f in d["files"]:
assert_keys(f, "path", "sizeBytes", "sizeKB", "estTokens", "status")
assert f["status"] in ("ok", "warning", "critical")
+
+
+# ---------------------------------------------------------------------------
+# Quick Actions (GH #252)
+# ---------------------------------------------------------------------------
+
+class TestQuickActions:
+ """Tests for the Quick Actions panel API (GH #252)."""
+
+ def test_actions_run_requires_post(self, api, base_url):
+ """GET /api/actions/run returns 405 (POST only)."""
+ r = api.get(f"{base_url}/api/actions/run", timeout=5)
+ assert r.status_code == 405, f"Expected 405, got {r.status_code}"
+
+ def test_actions_run_rejects_unknown_action(self, api, base_url):
+ """POST with unknown action returns 400."""
+ r = api.post(
+ f"{base_url}/api/actions/run",
+ json={"action": "nuke-everything"},
+ timeout=10,
+ )
+ assert r.status_code == 400, f"Expected 400, got {r.status_code}"
+ d = r.json()
+ assert d.get("ok") is False
+
+ def test_actions_run_health_check_returns_ok(self, api, base_url):
+ """health-check action returns ok=True with output."""
+ r = api.post(
+ f"{base_url}/api/actions/run",
+ json={"action": "health-check"},
+ timeout=15,
+ )
+ assert r.status_code == 200, f"Expected 200, got {r.status_code}: {r.text[:200]}"
+ d = r.json()
+ assert_keys(d, "ok", "action", "output", "duration_ms")
+ assert d["action"] == "health-check"
+ assert isinstance(d["output"], str)
+ assert isinstance(d["duration_ms"], int)
+
+ def test_actions_run_clear_cache_returns_ok(self, api, base_url):
+ """clear-cache action returns ok=True."""
+ r = api.post(
+ f"{base_url}/api/actions/run",
+ json={"action": "clear-cache"},
+ timeout=10,
+ )
+ assert r.status_code == 200, f"Expected 200, got {r.status_code}: {r.text[:200]}"
+ d = r.json()
+ assert d.get("ok") is True
+ assert "cache" in d.get("output", "").lower()
+
+ def test_actions_history_structure(self, api, base_url):
+ """GET /api/actions/history returns list with required fields."""
+ d = assert_ok(get(api, base_url, "/api/actions/history"))
+ assert_keys(d, "actions", "total")
+ assert isinstance(d["actions"], list)
+ assert isinstance(d["total"], int)