mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
The webhook 'platform disabled' card told users to enable it 'in your messaging settings' — no such page exists. The webhook platform is enabled on the Channels page (nav label), matching how every other dashboard page refers to it.
483 lines
16 KiB
TypeScript
483 lines
16 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
|
import { Webhook, Plus, Trash2, X, Copy, Check } 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";
|
|
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
|
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
|
import { api } from "@/lib/api";
|
|
import type { WebhookRoute, WebhooksResponse } from "@/lib/api";
|
|
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
|
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
|
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
|
|
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
|
import { Toast } from "@nous-research/ui/ui/components/toast";
|
|
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
|
import { Input } from "@nous-research/ui/ui/components/input";
|
|
import { Label } from "@nous-research/ui/ui/components/label";
|
|
import { usePageHeader } from "@/contexts/usePageHeader";
|
|
import { cn, themedBody } from "@/lib/utils";
|
|
|
|
interface CreatedWebhook {
|
|
url: string;
|
|
secret: string;
|
|
}
|
|
|
|
function CopyButton({ value }: { value: string }) {
|
|
const [copied, setCopied] = useState(false);
|
|
const handleCopy = useCallback(() => {
|
|
navigator.clipboard
|
|
.writeText(value)
|
|
.then(() => {
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 1500);
|
|
})
|
|
.catch(() => {});
|
|
}, [value]);
|
|
return (
|
|
<Button
|
|
ghost
|
|
size="icon"
|
|
title="Copy"
|
|
aria-label="Copy"
|
|
onClick={handleCopy}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
>
|
|
{copied ? <Check /> : <Copy />}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
export default function WebhooksPage() {
|
|
const [data, setData] = useState<WebhooksResponse | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const { toast, showToast } = useToast();
|
|
const { setEnd } = usePageHeader();
|
|
|
|
// New subscription modal state
|
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
|
const [name, setName] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [events, setEvents] = useState("");
|
|
const [deliver, setDeliver] = useState("log");
|
|
const [deliverOnly, setDeliverOnly] = useState(false);
|
|
const [prompt, setPrompt] = useState("");
|
|
const [creating, setCreating] = useState(false);
|
|
const [created, setCreated] = useState<CreatedWebhook | null>(null);
|
|
|
|
const closeCreateModal = useCallback(() => {
|
|
setCreateModalOpen(false);
|
|
setCreated(null);
|
|
}, []);
|
|
const createModalRef = useModalBehavior({
|
|
open: createModalOpen,
|
|
onClose: closeCreateModal,
|
|
});
|
|
|
|
const enabled = data?.enabled ?? false;
|
|
const subscriptions = data?.subscriptions ?? [];
|
|
|
|
const loadWebhooks = useCallback(() => {
|
|
api
|
|
.getWebhooks()
|
|
.then(setData)
|
|
.catch(() => showToast("Failed to load webhooks", "error"))
|
|
.finally(() => setLoading(false));
|
|
}, [showToast]);
|
|
|
|
useEffect(() => {
|
|
loadWebhooks();
|
|
}, [loadWebhooks]);
|
|
|
|
const resetForm = useCallback(() => {
|
|
setName("");
|
|
setDescription("");
|
|
setEvents("");
|
|
setDeliver("log");
|
|
setDeliverOnly(false);
|
|
setPrompt("");
|
|
}, []);
|
|
|
|
const handleCreate = async () => {
|
|
if (!name.trim()) {
|
|
showToast("Name required", "error");
|
|
return;
|
|
}
|
|
setCreating(true);
|
|
try {
|
|
const eventsList = events
|
|
.split(",")
|
|
.map((e) => e.trim())
|
|
.filter(Boolean);
|
|
const res = await api.createWebhook({
|
|
name: name.trim(),
|
|
description: description.trim() || undefined,
|
|
events: eventsList.length ? eventsList : undefined,
|
|
deliver,
|
|
deliver_only: deliverOnly,
|
|
prompt: prompt.trim() || undefined,
|
|
});
|
|
showToast("Created ✓", "success");
|
|
setCreated({ url: res.url, secret: res.secret });
|
|
resetForm();
|
|
loadWebhooks();
|
|
} catch (e) {
|
|
showToast(`Failed to create: ${e}`, "error");
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
const [togglingName, setTogglingName] = useState<string | null>(null);
|
|
|
|
const handleToggleEnabled = useCallback(
|
|
async (subName: string, nextEnabled: boolean) => {
|
|
setTogglingName(subName);
|
|
try {
|
|
await api.setWebhookEnabled(subName, nextEnabled);
|
|
showToast(
|
|
nextEnabled ? `Enabled: "${subName}"` : `Disabled: "${subName}"`,
|
|
"success",
|
|
);
|
|
loadWebhooks();
|
|
} catch (e) {
|
|
showToast(`Error: ${e}`, "error");
|
|
} finally {
|
|
setTogglingName(null);
|
|
}
|
|
},
|
|
[loadWebhooks, showToast],
|
|
);
|
|
|
|
const webhookDelete = useConfirmDelete({
|
|
onDelete: useCallback(
|
|
async (name: string) => {
|
|
try {
|
|
await api.deleteWebhook(name);
|
|
showToast(`Deleted: "${name}"`, "success");
|
|
loadWebhooks();
|
|
} catch (e) {
|
|
showToast(`Error: ${e}`, "error");
|
|
throw e;
|
|
}
|
|
},
|
|
[loadWebhooks, showToast],
|
|
),
|
|
});
|
|
|
|
// Put "New subscription" button in page header
|
|
useLayoutEffect(() => {
|
|
setEnd(
|
|
<Button
|
|
className="uppercase"
|
|
size="sm"
|
|
disabled={!enabled}
|
|
prefix={<Plus />}
|
|
onClick={() => {
|
|
setCreated(null);
|
|
setCreateModalOpen(true);
|
|
}}
|
|
>
|
|
New subscription
|
|
</Button>,
|
|
);
|
|
return () => {
|
|
setEnd(null);
|
|
};
|
|
}, [setEnd, enabled, loading]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-24">
|
|
<Spinner className="text-2xl text-primary" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const pendingName = webhookDelete.pendingId ?? "";
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<Toast toast={toast} />
|
|
|
|
<DeleteConfirmDialog
|
|
open={webhookDelete.isOpen}
|
|
onCancel={webhookDelete.cancel}
|
|
onConfirm={webhookDelete.confirm}
|
|
title="Delete webhook"
|
|
description={
|
|
pendingName
|
|
? `"${pendingName}" — this will permanently remove this webhook subscription.`
|
|
: "This will permanently remove this webhook subscription."
|
|
}
|
|
loading={webhookDelete.isDeleting}
|
|
/>
|
|
|
|
{/* Create subscription modal */}
|
|
{createModalOpen && (
|
|
<div
|
|
ref={createModalRef}
|
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
|
onClick={(e) => e.target === e.currentTarget && closeCreateModal()}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="create-webhook-title"
|
|
>
|
|
<div className={cn(themedBody, "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col max-h-[90vh] overflow-y-auto")}>
|
|
<Button
|
|
ghost
|
|
size="icon"
|
|
onClick={closeCreateModal}
|
|
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
|
|
aria-label="Close"
|
|
>
|
|
<X />
|
|
</Button>
|
|
|
|
<header className="p-5 pb-3 border-b border-border">
|
|
<h2
|
|
id="create-webhook-title"
|
|
className="font-mondwest text-display text-base tracking-wider"
|
|
>
|
|
New subscription
|
|
</h2>
|
|
</header>
|
|
|
|
{created ? (
|
|
<div className="p-5 grid gap-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Subscription created. Copy the secret now — it is only shown
|
|
once.
|
|
</p>
|
|
|
|
<div className="grid gap-2">
|
|
<Label>Webhook URL</Label>
|
|
<div className="flex items-center gap-2 border border-border bg-background/40 px-3 py-2">
|
|
<span className="flex-1 min-w-0 truncate font-mono text-xs">
|
|
{created.url}
|
|
</span>
|
|
<CopyButton value={created.url} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label>Secret (shown once)</Label>
|
|
<div className="flex items-center gap-2 border border-warning/40 bg-warning/10 px-3 py-2">
|
|
<span className="flex-1 min-w-0 truncate font-mono text-xs">
|
|
{created.secret}
|
|
</span>
|
|
<CopyButton value={created.secret} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<Button
|
|
className="uppercase"
|
|
size="sm"
|
|
onClick={closeCreateModal}
|
|
>
|
|
Done
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="p-5 grid gap-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="webhook-name">Name</Label>
|
|
<Input
|
|
id="webhook-name"
|
|
autoFocus
|
|
placeholder="e.g. github-push"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="webhook-description">Description</Label>
|
|
<Input
|
|
id="webhook-description"
|
|
placeholder="What this webhook does (optional)"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="webhook-events">Events</Label>
|
|
<Input
|
|
id="webhook-events"
|
|
placeholder="comma-separated, leave empty for all"
|
|
value={events}
|
|
onChange={(e) => setEvents(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="webhook-deliver">Deliver to</Label>
|
|
<Select
|
|
id="webhook-deliver"
|
|
value={deliver}
|
|
onValueChange={(v) => setDeliver(v)}
|
|
>
|
|
<SelectOption value="log">Log</SelectOption>
|
|
<SelectOption value="telegram">Telegram</SelectOption>
|
|
<SelectOption value="discord">Discord</SelectOption>
|
|
<SelectOption value="slack">Slack</SelectOption>
|
|
<SelectOption value="email">Email</SelectOption>
|
|
<SelectOption value="github_comment">
|
|
GitHub comment
|
|
</SelectOption>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="webhook-deliver-only">Deliver only</Label>
|
|
<label className="flex items-center gap-2 text-sm text-muted-foreground h-9">
|
|
<input
|
|
id="webhook-deliver-only"
|
|
type="checkbox"
|
|
checked={deliverOnly}
|
|
onChange={(e) => setDeliverOnly(e.target.checked)}
|
|
/>
|
|
Skip the agent, deliver payload directly
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="webhook-prompt">Prompt</Label>
|
|
<textarea
|
|
id="webhook-prompt"
|
|
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
|
|
placeholder="Instructions for the agent when this webhook fires (optional)"
|
|
value={prompt}
|
|
onChange={(e) => setPrompt(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<Button
|
|
className="uppercase"
|
|
size="sm"
|
|
onClick={handleCreate}
|
|
disabled={creating}
|
|
prefix={creating ? <Spinner /> : undefined}
|
|
>
|
|
{creating ? "Creating…" : "Create"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!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.
|
|
</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-3">
|
|
<H2
|
|
variant="sm"
|
|
className="flex items-center gap-2 text-muted-foreground"
|
|
>
|
|
<Webhook className="h-4 w-4" />
|
|
Subscriptions ({subscriptions.length})
|
|
</H2>
|
|
|
|
<p className="text-xs text-muted-foreground -mt-1">
|
|
Disabled webhooks reject incoming events; the gateway hot-reloads
|
|
changes (no restart needed).
|
|
</p>
|
|
|
|
{subscriptions.length === 0 && (
|
|
<Card>
|
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
|
No webhook subscriptions yet.
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{subscriptions.map((sub: WebhookRoute) => (
|
|
<Card key={sub.name}>
|
|
<CardContent className="flex items-start gap-4 py-4">
|
|
<div className={cn("flex-1 min-w-0", !sub.enabled && "opacity-60")}>
|
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
<span className="font-medium text-sm truncate">
|
|
{sub.name}
|
|
</span>
|
|
<Badge tone="outline">{sub.deliver}</Badge>
|
|
{sub.deliver_only && (
|
|
<Badge tone="secondary">deliver only</Badge>
|
|
)}
|
|
{!sub.enabled && <Badge tone="warning">disabled</Badge>}
|
|
</div>
|
|
|
|
{sub.description && (
|
|
<p className="text-xs text-muted-foreground mb-2">
|
|
{sub.description}
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex items-center gap-1 flex-wrap mb-2">
|
|
{sub.events.length === 0 ? (
|
|
<Badge tone="secondary">(all)</Badge>
|
|
) : (
|
|
sub.events.map((evt) => (
|
|
<Badge key={evt} tone="secondary">
|
|
{evt}
|
|
</Badge>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<span className="flex-1 min-w-0 truncate font-mono">
|
|
{sub.url}
|
|
</span>
|
|
<CopyButton value={sub.url} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
<Button
|
|
ghost
|
|
size="sm"
|
|
className="uppercase"
|
|
disabled={togglingName === sub.name}
|
|
onClick={() => handleToggleEnabled(sub.name, !sub.enabled)}
|
|
>
|
|
{sub.enabled ? "Disable" : "Enable"}
|
|
</Button>
|
|
<Button
|
|
ghost
|
|
destructive
|
|
size="icon"
|
|
title="Delete"
|
|
aria-label="Delete"
|
|
onClick={() => webhookDelete.requestDelete(sub.name)}
|
|
>
|
|
<Trash2 />
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|