Enable webhooks from dashboard page

This commit is contained in:
Shannon Sands 2026-06-11 14:54:47 +10:00 committed by Teknium
parent 13f1efdd15
commit fa7f24e898
4 changed files with 291 additions and 17 deletions

View file

@ -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(" ", "-")

View file

@ -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"

View file

@ -866,6 +866,8 @@ export const api = {
// ── Admin: Webhooks ─────────────────────────────────────────────────
getWebhooks: () => fetchJSON<WebhooksResponse>("/api/webhooks"),
enableWebhooks: () =>
fetchJSON<WebhookEnableResponse>("/api/webhooks/enable", { method: "POST" }),
createWebhook: (body: WebhookCreate) =>
fetchJSON<WebhookRoute & { secret: string }>("/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;

View file

@ -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<WebhooksResponse | null>(null);
const [loading, setLoading] = useState(true);
const [enabling, setEnabling] = useState(false);
const [restartNeeded, setRestartNeeded] = useState(false);
const [restartMessage, setRestartMessage] = useState<string | null>(null);
const [restartError, setRestartError] = useState<string | null>(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() {
<Button
className="uppercase"
size="sm"
disabled={!enabled}
disabled={!enabled || enabling}
prefix={<Plus />}
onClick={() => {
setCreated(null);
@ -184,7 +270,7 @@ export default function WebhooksPage() {
return () => {
setEnd(null);
};
}, [setEnd, enabled, loading]);
}, [setEnd, enabled, enabling, loading]);
if (loading) {
return (
@ -375,17 +461,61 @@ export default function WebhooksPage() {
)}
{!enabled && (
<Card>
<CardContent className="py-6 flex items-start gap-3 text-sm">
<Webhook className="h-5 w-5 shrink-0 text-warning" />
<div className="flex flex-col gap-1">
<span className="font-medium">Webhook platform disabled</span>
<span className="text-muted-foreground">
The webhook platform must be enabled on the Channels page before
you can create subscriptions. Enable it there, then return to
this page.
<Card className="border-warning/50">
<CardContent className="flex flex-col gap-4 py-6 text-sm sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3">
<Webhook className="h-5 w-5 shrink-0 text-warning" />
<div className="flex flex-col gap-1">
<span className="font-medium">Webhook receiver disabled</span>
<span className="text-muted-foreground">
Webhooks are their own gateway platform. Enable them here to
accept incoming HTTP events; chat channels are only needed
when a subscription delivers to Telegram, Discord, Slack, or
another channel.
</span>
</div>
</div>
<Button
size="sm"
className="uppercase shrink-0"
onClick={handleEnableWebhooks}
disabled={enabling}
prefix={enabling ? <Spinner /> : <Webhook className="h-4 w-4" />}
>
{enabling ? "Enabling…" : "Enable webhooks"}
</Button>
</CardContent>
</Card>
)}
{restartMessage && !restartNeeded && (
<Card className="border-border">
<CardContent className="flex items-center gap-2 p-4 text-sm text-muted-foreground">
<RotateCw className="h-4 w-4 shrink-0 text-warning" />
<span>{restartMessage}</span>
</CardContent>
</Card>
)}
{restartNeeded && (
<Card className="border-warning/50">
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-2 text-sm">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<span>
{restartError ??
"Webhooks are enabled, but the gateway still needs a restart before the receiver can come online."}
</span>
</div>
<Button
size="sm"
className="uppercase shrink-0"
onClick={handleRestart}
disabled={restarting}
prefix={restarting ? <Spinner /> : <RotateCw className="h-4 w-4" />}
>
{restarting ? "Restarting…" : "Restart gateway"}
</Button>
</CardContent>
</Card>
)}
@ -400,8 +530,8 @@ export default function WebhooksPage() {
</H2>
<p className="text-xs text-muted-foreground -mt-1">
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.
</p>
{subscriptions.length === 0 && (