From fa7f24e8980367c2ca849eb99e1eb2331c7d3699 Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Thu, 11 Jun 2026 14:54:47 +1000 Subject: [PATCH] Enable webhooks from dashboard page --- hermes_cli/web_server.py | 45 ++++- .../test_dashboard_admin_endpoints.py | 90 +++++++++- web/src/lib/api.ts | 13 ++ web/src/pages/WebhooksPage.tsx | 160 ++++++++++++++++-- 4 files changed, 291 insertions(+), 17 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index f7cc695874a..ef1c15bac93 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1707,6 +1707,28 @@ def _spawn_gateway_restart() -> Tuple[subprocess.Popen, bool]: return _spawn_hermes_action(["gateway", "restart"], "gateway-restart"), False +def _restart_gateway_after_webhook_enable() -> dict[str, Any]: + """Best-effort gateway restart after enabling the webhook platform.""" + try: + proc, reused = _spawn_gateway_restart() + except Exception as exc: + _log.exception("Failed to auto-restart gateway after enabling webhooks") + return { + "restart_started": False, + "restart_error": str(exc), + } + if reused: + _log.info( + "Webhook enable: reusing in-flight gateway restart (pid %s)", + proc.pid, + ) + return { + "restart_started": True, + "restart_action": "gateway-restart", + "restart_pid": proc.pid, + } + + @app.post("/api/gateway/restart") async def restart_gateway(): """Kick off a ``hermes gateway restart`` in the background.""" @@ -6753,6 +6775,27 @@ async def list_webhooks(): } +@app.post("/api/webhooks/enable") +async def enable_webhooks(): + try: + _write_platform_enabled("webhook", True) + except Exception as exc: + _log.exception("Failed to enable webhook platform from dashboard") + raise HTTPException( + status_code=500, + detail="Failed to enable webhook platform.", + ) from exc + + restart_result = _restart_gateway_after_webhook_enable() + return { + "ok": True, + "platform": "webhook", + "enabled": True, + "needs_restart": not restart_result["restart_started"], + **restart_result, + } + + @app.post("/api/webhooks") async def create_webhook(body: WebhookCreate): import re as _re @@ -6763,7 +6806,7 @@ async def create_webhook(body: WebhookCreate): if not wh._is_webhook_enabled(): raise HTTPException( status_code=400, - detail="Webhook platform is not enabled. Enable it in messaging settings first.", + detail="Webhook platform is not enabled. Enable it from the Webhooks page first.", ) name = (body.name or "").strip().lower().replace(" ", "-") diff --git a/tests/hermes_cli/test_dashboard_admin_endpoints.py b/tests/hermes_cli/test_dashboard_admin_endpoints.py index 5171f3ade05..60ee50728a1 100644 --- a/tests/hermes_cli/test_dashboard_admin_endpoints.py +++ b/tests/hermes_cli/test_dashboard_admin_endpoints.py @@ -201,6 +201,91 @@ class TestWebhookEndpoints: r = self.client.post("/api/webhooks", json={"name": "gh", "deliver": "log"}) assert r.status_code == 400 + def test_enable_platform_starts_gateway_restart(self, monkeypatch): + import hermes_cli.web_server as ws + from hermes_cli.config import load_config + + ws._ACTION_PROCS.pop("gateway-restart", None) + restart_calls = [] + + class FakeRestartProc: + pid = 4242 + + def fake_spawn_action(subcommand, name): + restart_calls.append((subcommand, name)) + return FakeRestartProc() + + monkeypatch.setattr(ws, "_spawn_hermes_action", fake_spawn_action) + + r = self.client.post("/api/webhooks/enable") + + assert r.status_code == 200 + assert r.json() == { + "ok": True, + "platform": "webhook", + "enabled": True, + "needs_restart": False, + "restart_started": True, + "restart_action": "gateway-restart", + "restart_pid": 4242, + } + assert restart_calls == [(["gateway", "restart"], "gateway-restart")] + assert load_config()["platforms"]["webhook"]["enabled"] is True + assert self.client.get("/api/webhooks").json()["enabled"] is True + + def test_enable_platform_reports_restart_failure_after_save(self, monkeypatch): + import hermes_cli.web_server as ws + from hermes_cli.config import load_config + + ws._ACTION_PROCS.pop("gateway-restart", None) + + def fail_spawn_action(subcommand, name): + assert subcommand == ["gateway", "restart"] + assert name == "gateway-restart" + raise RuntimeError("supervisor unavailable") + + monkeypatch.setattr(ws, "_spawn_hermes_action", fail_spawn_action) + + r = self.client.post("/api/webhooks/enable") + + assert r.status_code == 200 + data = r.json() + assert data["ok"] is True + assert data["platform"] == "webhook" + assert data["enabled"] is True + assert data["needs_restart"] is True + assert data["restart_started"] is False + assert "supervisor unavailable" in data["restart_error"] + assert load_config()["platforms"]["webhook"]["enabled"] is True + + def test_enable_platform_reuses_inflight_gateway_restart(self, monkeypatch): + import hermes_cli.web_server as ws + from hermes_cli.config import load_config + + ws._ACTION_PROCS.pop("gateway-restart", None) + + class FakeRunningProc: + pid = 5151 + + def poll(self): + return None + + monkeypatch.setitem(ws._ACTION_PROCS, "gateway-restart", FakeRunningProc()) + + def fail_spawn_action(subcommand, name): + raise AssertionError("must not spawn a second concurrent restart") + + monkeypatch.setattr(ws, "_spawn_hermes_action", fail_spawn_action) + + r = self.client.post("/api/webhooks/enable") + + assert r.status_code == 200 + data = r.json() + assert data["needs_restart"] is False + assert data["restart_started"] is True + assert data["restart_pid"] == 5151 + assert load_config()["platforms"]["webhook"]["enabled"] is True + class TestOpsEndpoints: @pytest.fixture(autouse=True) @@ -622,6 +707,10 @@ class TestAdminEndpointsAuthGate: resp = self.client.get(path) assert resp.status_code in (401, 403) + def test_webhooks_enable_post_gated(self): + resp = self.client.post("/api/webhooks/enable") + assert resp.status_code in (401, 403) + class TestUpdateCheckEndpoint: """``GET /api/hermes/update/check`` reports availability without applying. @@ -953,4 +1042,3 @@ class TestToolsConfigEndpoints: kwargs["json"] = payload r = fn(path, **kwargs) assert r.status_code == 401, f"{method} {path} not gated" - diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 7cde6eb78f9..a7c308353bb 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -866,6 +866,8 @@ export const api = { // ── Admin: Webhooks ───────────────────────────────────────────────── getWebhooks: () => fetchJSON("/api/webhooks"), + enableWebhooks: () => + fetchJSON("/api/webhooks/enable", { method: "POST" }), createWebhook: (body: WebhookCreate) => fetchJSON("/api/webhooks", { method: "POST", @@ -1288,6 +1290,17 @@ export interface WebhooksResponse { subscriptions: WebhookRoute[]; } +export interface WebhookEnableResponse { + ok: boolean; + platform: "webhook"; + enabled: true; + needs_restart: boolean; + restart_started?: boolean; + restart_action?: string; + restart_pid?: number | null; + restart_error?: string; +} + export interface WebhookCreate { name: string; description?: string; diff --git a/web/src/pages/WebhooksPage.tsx b/web/src/pages/WebhooksPage.tsx index 13587efd190..30470f45259 100644 --- a/web/src/pages/WebhooksPage.tsx +++ b/web/src/pages/WebhooksPage.tsx @@ -1,5 +1,14 @@ import { useCallback, useEffect, useLayoutEffect, useState } from "react"; -import { Webhook, Plus, Trash2, X, Copy, Check } from "lucide-react"; +import { + AlertTriangle, + Check, + Copy, + Plus, + RotateCw, + Trash2, + Webhook, + X, +} from "lucide-react"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Button } from "@nous-research/ui/ui/components/button"; import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; @@ -51,6 +60,11 @@ function CopyButton({ value }: { value: string }) { export default function WebhooksPage() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); + const [enabling, setEnabling] = useState(false); + const [restartNeeded, setRestartNeeded] = useState(false); + const [restartMessage, setRestartMessage] = useState(null); + const [restartError, setRestartError] = useState(null); + const [restarting, setRestarting] = useState(false); const { toast, showToast } = useToast(); const { setEnd } = usePageHeader(); @@ -78,7 +92,7 @@ export default function WebhooksPage() { const subscriptions = data?.subscriptions ?? []; const loadWebhooks = useCallback(() => { - api + return api .getWebhooks() .then(setData) .catch(() => showToast("Failed to load webhooks", "error")) @@ -89,6 +103,78 @@ export default function WebhooksPage() { loadWebhooks(); }, [loadWebhooks]); + const watchRestartOutcome = useCallback(async () => { + for (let i = 0; i < 20; i++) { + await new Promise((resolve) => setTimeout(resolve, 1500)); + try { + const st = await api.getActionStatus("gateway-restart", 5); + if (st.running) continue; + if (st.exit_code !== 0 && st.exit_code !== null) { + setRestartMessage(null); + setRestartNeeded(true); + setRestartError(`Gateway restart failed with exit ${st.exit_code}.`); + showToast( + `Gateway restart failed (exit ${st.exit_code}) — restart manually`, + "error", + ); + } else { + setRestartMessage(null); + setRestartNeeded(false); + setRestartError(null); + } + return; + } catch { + // The dashboard may briefly lose its connection while the gateway restarts. + } + } + setRestartMessage(null); + }, [showToast]); + + const handleRestart = useCallback(async () => { + setRestarting(true); + try { + await api.restartGateway(); + setRestartNeeded(false); + setRestartError(null); + setRestartMessage("Gateway restarting…"); + showToast("Gateway restarting…", "success"); + setTimeout(() => void loadWebhooks(), 4000); + void watchRestartOutcome(); + } catch (e) { + setRestartNeeded(true); + setRestartError(String(e)); + showToast(`Failed to restart: ${e}`, "error"); + } finally { + setRestarting(false); + } + }, [loadWebhooks, showToast, watchRestartOutcome]); + + const handleEnableWebhooks = useCallback(async () => { + setEnabling(true); + setRestartNeeded(false); + setRestartError(null); + try { + const result = await api.enableWebhooks(); + await loadWebhooks(); + if (result.restart_started) { + setRestartMessage("Webhooks enabled; gateway restarting…"); + showToast("Webhooks enabled; gateway restarting…", "success"); + setTimeout(() => void loadWebhooks(), 4000); + void watchRestartOutcome(); + } else { + const detail = result.restart_error ? `: ${result.restart_error}` : "."; + setRestartMessage(null); + setRestartNeeded(true); + setRestartError(`Gateway restart failed${detail}`); + showToast(`Webhooks enabled; gateway restart failed${detail}`, "error"); + } + } catch (e) { + showToast(`Failed to enable webhooks: ${e}`, "error"); + } finally { + setEnabling(false); + } + }, [loadWebhooks, showToast, watchRestartOutcome]); + const resetForm = useCallback(() => { setName(""); setDescription(""); @@ -171,7 +257,7 @@ export default function WebhooksPage() { + + + )} + + {restartMessage && !restartNeeded && ( + + + + {restartMessage} + + + )} + + {restartNeeded && ( + + +
+ + + {restartError ?? + "Webhooks are enabled, but the gateway still needs a restart before the receiver can come online."}
+
)} @@ -400,8 +530,8 @@ export default function WebhooksPage() {

- Disabled webhooks reject incoming events; the gateway hot-reloads - changes (no restart needed). + Subscription changes hot-reload once the webhook receiver is running. + Disabled subscriptions reject incoming events.

{subscriptions.length === 0 && (