mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-11 08:42:11 +00:00
Enable webhooks from dashboard page
This commit is contained in:
parent
13f1efdd15
commit
fa7f24e898
4 changed files with 291 additions and 17 deletions
|
|
@ -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(" ", "-")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue