mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-17 06:14:48 +00:00
feat(webhooks): add webhook management UI with delivery history & test (#1211)
* feat(webhooks): add webhook management UI with delivery history & test
Webhooks admin page on the web dashboard for managing inbound HTTP webhooks
(llm + message kinds), backed by new admin endpoints. No schema changes
(reuses migrations 000059-000061).
Backend (internal/http):
- GET /v1/webhooks/{id}/calls - paginated delivery history (trimmed
DTO; status/limit/offset filters; ownership + tenant scoped)
- GET /v1/webhooks/{id}/calls/{callId} - full single-call detail (request
payload, full response, callback URL, idempotency key, timestamps)
- POST /v1/webhooks/{id}/test - server-side test invocation using the
admin session (no secret); dispatched by kind via RunTest() on the llm/message
handlers; message tester nil-guarded (403 on Lite)
Injects WebhookCallStore + SetTesters() in cmd/gateway_http_wiring.go, adds i18n
key webhook.message_test_requires_standard (en/vi/zh), and unit tests for
list/detail (filter, tenant isolation) and test (success/error/edition gate).
Frontend (ui/web):
- /webhooks admin-only page: list with revoked filter + badge, create/edit form
(edition-gated message kind, Lite localhost_only lock), show-once secret dialog
(create + rotate), test dialog, and delivery-history dialog with server-side
pagination plus a click-through full call-detail dialog.
- use-webhooks hooks, query keys, types, routing, sidebar entry (Cable icon to
distinguish from the existing event "Hooks" page), webhooks i18n namespace
(en/vi/zh).
* fix(webhooks): validate admin message tests
---------
Co-authored-by: Goon <duy@wearetopgroup.com>
This commit is contained in:
1 parent
a5a853f461
commit
21fb1f18ee
31 files changed
+3087
-97
No files matched your search
@@ -176,9 +176,13 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer(
|
||||
|
||||
// Webhook admin CRUD — available in all editions (Standard + Lite).
|
||||
// Runtime routes (/v1/webhooks/message, /v1/webhooks/llm) are mounted by phases 05/06.
|
||||
// adminH is captured so the test endpoint (POST /v1/webhooks/{id}/test) can be wired
|
||||
// with the runtime invokers (llm/message handlers) once they are constructed below.
|
||||
var adminH *httpapi.WebhooksAdminHandler
|
||||
if d.pgStores != nil && d.pgStores.Webhooks != nil {
|
||||
adminH := httpapi.NewWebhooksAdminHandler(
|
||||
adminH = httpapi.NewWebhooksAdminHandler(
|
||||
d.pgStores.Webhooks,
|
||||
d.pgStores.WebhookCalls,
|
||||
d.pgStores.Tenants,
|
||||
d.msgBus,
|
||||
)
|
||||
@@ -188,13 +192,14 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer(
|
||||
|
||||
// Webhook message endpoint — Standard edition only (channels required).
|
||||
// Phase 05b: POST /v1/webhooks/message → sync channel send (text + optional media).
|
||||
var msgH *httpapi.WebhookMessageHandler
|
||||
if edition.Current().AllowsChannels() &&
|
||||
d.pgStores != nil &&
|
||||
d.pgStores.Webhooks != nil &&
|
||||
d.pgStores.WebhookCalls != nil &&
|
||||
d.pgStores.ChannelInstances != nil &&
|
||||
d.channelMgr != nil {
|
||||
msgH := httpapi.NewWebhookMessageHandler(
|
||||
msgH = httpapi.NewWebhookMessageHandler(
|
||||
d.channelMgr,
|
||||
d.pgStores.ChannelInstances,
|
||||
d.pgStores.WebhookCalls,
|
||||
@@ -209,11 +214,12 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer(
|
||||
// Phase 06: POST /v1/webhooks/llm → sync agent run (≤30s) or async enqueue.
|
||||
// LocalhostOnly enforcement is handled by WebhookAuthMiddleware at request time.
|
||||
// lane=nil → handler self-creates internal default lane (4-slot).
|
||||
var llmH *httpapi.WebhookLLMHandler
|
||||
if d.pgStores != nil &&
|
||||
d.pgStores.Webhooks != nil &&
|
||||
d.pgStores.WebhookCalls != nil &&
|
||||
d.agentRouter != nil {
|
||||
llmH := httpapi.NewWebhookLLMHandler(
|
||||
llmH = httpapi.NewWebhookLLMHandler(
|
||||
d.agentRouter,
|
||||
d.pgStores.WebhookCalls,
|
||||
d.pgStores.Webhooks,
|
||||
@@ -223,6 +229,12 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer(
|
||||
llmH.SetEncKey(webhookEncKey) // K6: decrypt secret at HMAC verify time
|
||||
d.server.SetWebhookLLMHandler(llmH)
|
||||
}
|
||||
|
||||
// Wire the admin test endpoint with runtime invokers. msgH is nil on Lite — the
|
||||
// admin handler guards on nil and rejects message tests there.
|
||||
if adminH != nil {
|
||||
adminH.SetTesters(llmH, msgH)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow browser-paired users to access HTTP APIs
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base32"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -30,21 +32,38 @@ var webhookKinds = map[string]bool{
|
||||
"message": true,
|
||||
}
|
||||
|
||||
// webhookLLMTester runs a server-side test invocation of an llm-kind webhook using the
|
||||
// admin's already-authorized session (no webhook secret required). Implemented by *WebhookLLMHandler.
|
||||
type webhookLLMTester interface {
|
||||
RunTest(ctx context.Context, wh *store.WebhookData, input, model string) (*webhookLLMSyncResp, error)
|
||||
}
|
||||
|
||||
// webhookMsgTester runs a server-side test invocation of a message-kind webhook. Implemented by
|
||||
// *WebhookMessageHandler. nil on Lite edition (channels unavailable) — handleTest guards on nil.
|
||||
type webhookMsgTester interface {
|
||||
RunTest(ctx context.Context, wh *store.WebhookData, req webhookMessageReq) (*webhookMessageResp, error)
|
||||
}
|
||||
|
||||
// WebhooksAdminHandler implements CRUD for webhook registry entries.
|
||||
// All endpoints are tenant-admin-gated (requireTenantAdmin).
|
||||
// encKey is the AES-256-GCM encryption key (GOCLAW_ENCRYPTION_KEY); if empty, encrypted_secret
|
||||
// is stored as "" and HMAC auth requires rotation before it can be used.
|
||||
type WebhooksAdminHandler struct {
|
||||
webhooks store.WebhookStore
|
||||
tenants store.TenantStore
|
||||
msgBus *bus.MessageBus
|
||||
encKey string // AES-256-GCM key for encrypting raw webhook secrets at rest
|
||||
webhooks store.WebhookStore
|
||||
calls store.WebhookCallStore
|
||||
tenants store.TenantStore
|
||||
msgBus *bus.MessageBus
|
||||
encKey string // AES-256-GCM key for encrypting raw webhook secrets at rest
|
||||
llmTester webhookLLMTester
|
||||
msgTester webhookMsgTester
|
||||
}
|
||||
|
||||
// NewWebhooksAdminHandler creates a handler for webhook admin endpoints.
|
||||
func NewWebhooksAdminHandler(webhooks store.WebhookStore, tenants store.TenantStore, msgBus *bus.MessageBus) *WebhooksAdminHandler {
|
||||
// calls may be nil — the delivery-history endpoint returns 503 when unset.
|
||||
func NewWebhooksAdminHandler(webhooks store.WebhookStore, calls store.WebhookCallStore, tenants store.TenantStore, msgBus *bus.MessageBus) *WebhooksAdminHandler {
|
||||
return &WebhooksAdminHandler{
|
||||
webhooks: webhooks,
|
||||
calls: calls,
|
||||
tenants: tenants,
|
||||
msgBus: msgBus,
|
||||
}
|
||||
@@ -56,6 +75,18 @@ func (h *WebhooksAdminHandler) SetEncKey(encKey string) {
|
||||
h.encKey = encKey
|
||||
}
|
||||
|
||||
// SetTesters wires the runtime invocation handlers used by POST /v1/webhooks/{id}/test.
|
||||
// Concrete pointer params (not interfaces) so callers can pass typed-nil safely: a nil
|
||||
// *WebhookMessageHandler on Lite leaves msgTester as a true nil interface. Safe to call at startup only.
|
||||
func (h *WebhooksAdminHandler) SetTesters(llm *WebhookLLMHandler, msg *WebhookMessageHandler) {
|
||||
if llm != nil {
|
||||
h.llmTester = llm
|
||||
}
|
||||
if msg != nil {
|
||||
h.msgTester = msg
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers all webhook admin routes on mux.
|
||||
// Admin CRUD routes mount for both editions.
|
||||
// Runtime routes (/v1/webhooks/message, /v1/webhooks/llm) are mounted by phases 05/06
|
||||
@@ -67,6 +98,9 @@ func (h *WebhooksAdminHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("PATCH /v1/webhooks/{id}", h.requireAdmin(h.handleUpdate))
|
||||
mux.HandleFunc("POST /v1/webhooks/{id}/rotate", h.requireAdmin(h.handleRotate))
|
||||
mux.HandleFunc("DELETE /v1/webhooks/{id}", h.requireAdmin(h.handleRevoke))
|
||||
mux.HandleFunc("GET /v1/webhooks/{id}/calls", h.requireAdmin(h.handleListCalls))
|
||||
mux.HandleFunc("GET /v1/webhooks/{id}/calls/{callId}", h.requireAdmin(h.handleGetCall))
|
||||
mux.HandleFunc("POST /v1/webhooks/{id}/test", h.requireAdmin(h.handleTest))
|
||||
}
|
||||
|
||||
func (h *WebhooksAdminHandler) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
@@ -532,6 +566,318 @@ func (h *WebhooksAdminHandler) handleRevoke(w http.ResponseWriter, r *http.Reque
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||
}
|
||||
|
||||
// --- List Calls (delivery history) ---
|
||||
|
||||
// webhookCallResp is the trimmed delivery-history record returned to the admin UI.
|
||||
// Raw request_payload is omitted (may be large / hold caller data); response is the
|
||||
// already-truncated (≤32 KB) audit body and is safe to surface for debugging.
|
||||
type webhookCallResp struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
DeliveryID uuid.UUID `json:"delivery_id"`
|
||||
Mode string `json:"mode"`
|
||||
Status string `json:"status"`
|
||||
Attempts int `json:"attempts"`
|
||||
NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastError *string `json:"last_error,omitempty"`
|
||||
Response string `json:"response,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
webhookCallsDefaultLimit = 50
|
||||
webhookCallsMaxLimit = 200
|
||||
)
|
||||
|
||||
func (h *WebhooksAdminHandler) handleListCalls(w http.ResponseWriter, r *http.Request) {
|
||||
locale := extractLocale(r)
|
||||
|
||||
if !requireTenantAdmin(w, r, h.tenants) {
|
||||
slog.Warn("security.webhook.admin_denied", "action", "list_calls", "path", r.URL.Path,
|
||||
"user_id", store.UserIDFromContext(r.Context()))
|
||||
return
|
||||
}
|
||||
|
||||
if h.calls == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "call store unavailable"))
|
||||
return
|
||||
}
|
||||
|
||||
id, ok := parseWebhookID(w, r, locale)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// Verify ownership before exposing call history (same pattern as handleGet).
|
||||
wh, err := h.webhooks.GetByID(ctx, id)
|
||||
if err != nil || wh == nil {
|
||||
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
||||
return
|
||||
}
|
||||
tenantID := store.TenantIDFromContext(ctx)
|
||||
if !store.IsOwnerRole(ctx) && wh.TenantID != tenantID {
|
||||
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
||||
return
|
||||
}
|
||||
|
||||
f := store.WebhookCallListFilter{WebhookID: &id, Limit: webhookCallsDefaultLimit}
|
||||
if s := r.URL.Query().Get("status"); s != "" {
|
||||
f.Status = s
|
||||
}
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if n, perr := strconv.Atoi(l); perr == nil && n > 0 {
|
||||
if n > webhookCallsMaxLimit {
|
||||
n = webhookCallsMaxLimit
|
||||
}
|
||||
f.Limit = n
|
||||
}
|
||||
}
|
||||
if o := r.URL.Query().Get("offset"); o != "" {
|
||||
if n, perr := strconv.Atoi(o); perr == nil && n >= 0 {
|
||||
f.Offset = n
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := h.calls.List(ctx, f)
|
||||
if err != nil {
|
||||
slog.Error("webhook.admin.list_calls_failed", "error", err, "id", id)
|
||||
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "webhook calls"))
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]webhookCallResp, 0, len(rows))
|
||||
for i := range rows {
|
||||
c := &rows[i]
|
||||
out = append(out, webhookCallResp{
|
||||
ID: c.ID,
|
||||
DeliveryID: c.DeliveryID,
|
||||
Mode: c.Mode,
|
||||
Status: c.Status,
|
||||
Attempts: c.Attempts,
|
||||
NextAttemptAt: c.NextAttemptAt,
|
||||
StartedAt: c.StartedAt,
|
||||
CompletedAt: c.CompletedAt,
|
||||
CreatedAt: c.CreatedAt,
|
||||
LastError: c.LastError,
|
||||
Response: string(c.Response),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// --- Get Call (single delivery detail) ---
|
||||
|
||||
// webhookCallDetailResp is the full delivery record for GET /v1/webhooks/{id}/calls/{callId}.
|
||||
// Unlike the list DTO it includes request_payload (canonical audit body) and the full response,
|
||||
// plus callback_url / idempotency_key — for debugging a single invocation.
|
||||
type webhookCallDetailResp struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
WebhookID uuid.UUID `json:"webhook_id"`
|
||||
AgentID *uuid.UUID `json:"agent_id,omitempty"`
|
||||
DeliveryID uuid.UUID `json:"delivery_id"`
|
||||
IdempotencyKey *string `json:"idempotency_key,omitempty"`
|
||||
Mode string `json:"mode"`
|
||||
Status string `json:"status"`
|
||||
CallbackURL *string `json:"callback_url,omitempty"`
|
||||
Attempts int `json:"attempts"`
|
||||
NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastError *string `json:"last_error,omitempty"`
|
||||
RequestPayload string `json:"request_payload,omitempty"`
|
||||
Response string `json:"response,omitempty"`
|
||||
}
|
||||
|
||||
func (h *WebhooksAdminHandler) handleGetCall(w http.ResponseWriter, r *http.Request) {
|
||||
locale := extractLocale(r)
|
||||
|
||||
if !requireTenantAdmin(w, r, h.tenants) {
|
||||
slog.Warn("security.webhook.admin_denied", "action", "get_call", "path", r.URL.Path,
|
||||
"user_id", store.UserIDFromContext(r.Context()))
|
||||
return
|
||||
}
|
||||
|
||||
if h.calls == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "call store unavailable"))
|
||||
return
|
||||
}
|
||||
|
||||
id, ok := parseWebhookID(w, r, locale)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
callID, err := uuid.Parse(r.PathValue("callId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "call"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// Verify webhook ownership first (same pattern as handleGet/handleListCalls).
|
||||
wh, err := h.webhooks.GetByID(ctx, id)
|
||||
if err != nil || wh == nil {
|
||||
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
||||
return
|
||||
}
|
||||
tenantID := store.TenantIDFromContext(ctx)
|
||||
if !store.IsOwnerRole(ctx) && wh.TenantID != tenantID {
|
||||
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
||||
return
|
||||
}
|
||||
|
||||
// GetByID is tenant-scoped; also verify the call belongs to THIS webhook.
|
||||
c, err := h.calls.GetByID(ctx, callID)
|
||||
if err != nil || c == nil || c.WebhookID != id {
|
||||
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook call", callID.String()))
|
||||
return
|
||||
}
|
||||
if !store.IsOwnerRole(ctx) && c.TenantID != tenantID {
|
||||
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook call", callID.String()))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, webhookCallDetailResp{
|
||||
ID: c.ID,
|
||||
WebhookID: c.WebhookID,
|
||||
AgentID: c.AgentID,
|
||||
DeliveryID: c.DeliveryID,
|
||||
IdempotencyKey: c.IdempotencyKey,
|
||||
Mode: c.Mode,
|
||||
Status: c.Status,
|
||||
CallbackURL: c.CallbackURL,
|
||||
Attempts: c.Attempts,
|
||||
NextAttemptAt: c.NextAttemptAt,
|
||||
StartedAt: c.StartedAt,
|
||||
CompletedAt: c.CompletedAt,
|
||||
CreatedAt: c.CreatedAt,
|
||||
LastError: c.LastError,
|
||||
RequestPayload: string(c.RequestPayload),
|
||||
Response: string(c.Response),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Test (server-side invocation) ---
|
||||
|
||||
// testWebhookReq is the request body for POST /v1/webhooks/{id}/test.
|
||||
// For kind=llm: input (+ optional model). For kind=message: channel_name/chat_id/content/media_*.
|
||||
type testWebhookReq struct {
|
||||
// llm fields
|
||||
Input string `json:"input,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
// message fields
|
||||
ChannelName string `json:"channel_name,omitempty"`
|
||||
ChatID string `json:"chat_id,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
MediaURL string `json:"media_url,omitempty"`
|
||||
MediaCaption string `json:"media_caption,omitempty"`
|
||||
FallbackToText bool `json:"fallback_to_text,omitempty"`
|
||||
}
|
||||
|
||||
func (h *WebhooksAdminHandler) handleTest(w http.ResponseWriter, r *http.Request) {
|
||||
locale := extractLocale(r)
|
||||
|
||||
if !requireTenantAdmin(w, r, h.tenants) {
|
||||
slog.Warn("security.webhook.admin_denied", "action", "test", "path", r.URL.Path,
|
||||
"user_id", store.UserIDFromContext(r.Context()))
|
||||
return
|
||||
}
|
||||
|
||||
id, ok := parseWebhookID(w, r, locale)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// Verify ownership before invoking.
|
||||
wh, err := h.webhooks.GetByID(ctx, id)
|
||||
if err != nil || wh == nil {
|
||||
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
||||
return
|
||||
}
|
||||
tenantID := store.TenantIDFromContext(ctx)
|
||||
if !store.IsOwnerRole(ctx) && wh.TenantID != tenantID {
|
||||
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
||||
return
|
||||
}
|
||||
if wh.Revoked {
|
||||
writeError(w, http.StatusConflict, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgWebhookRevoked))
|
||||
return
|
||||
}
|
||||
|
||||
var req testWebhookReq
|
||||
if !bindJSON(w, r, locale, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
switch wh.Kind {
|
||||
case "llm":
|
||||
if h.llmTester == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "llm tester unavailable"))
|
||||
return
|
||||
}
|
||||
if wh.AgentID == nil {
|
||||
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgWebhookAgentNotFound))
|
||||
return
|
||||
}
|
||||
if req.Input == "" {
|
||||
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "input"))
|
||||
return
|
||||
}
|
||||
resp, runErr := h.llmTester.RunTest(ctx, wh, req.Input, req.Model)
|
||||
if runErr != nil {
|
||||
slog.Warn("webhook.admin.test_failed", "id", id, "kind", "llm", "error", runErr)
|
||||
writeError(w, http.StatusBadGateway, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, runErr.Error()))
|
||||
return
|
||||
}
|
||||
slog.Info("webhook.tested", "id", id, "tenant_id", tenantID, "kind", "llm", "actor", extractUserID(r))
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
case "message":
|
||||
if h.msgTester == nil {
|
||||
writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgWebhookMessageTestRequiresStandard))
|
||||
return
|
||||
}
|
||||
if wh.ChannelID == nil && req.ChannelName == "" {
|
||||
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "channel_name"))
|
||||
return
|
||||
}
|
||||
if req.ChatID == "" {
|
||||
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "chat_id"))
|
||||
return
|
||||
}
|
||||
if req.Content == "" && req.MediaURL == "" {
|
||||
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "content"))
|
||||
return
|
||||
}
|
||||
if len(req.Content) > webhookContentMaxBytes {
|
||||
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "content exceeds 16 KB limit"))
|
||||
return
|
||||
}
|
||||
resp, runErr := h.msgTester.RunTest(ctx, wh, webhookMessageReq{
|
||||
ChannelName: req.ChannelName,
|
||||
ChatID: req.ChatID,
|
||||
Content: req.Content,
|
||||
MediaURL: req.MediaURL,
|
||||
MediaCaption: req.MediaCaption,
|
||||
FallbackToText: req.FallbackToText,
|
||||
})
|
||||
if runErr != nil {
|
||||
slog.Warn("webhook.admin.test_failed", "id", id, "kind", "message", "error", runErr)
|
||||
writeError(w, http.StatusBadGateway, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, runErr.Error()))
|
||||
return
|
||||
}
|
||||
slog.Info("webhook.tested", "id", id, "tenant_id", tenantID, "kind", "message", "actor", extractUserID(r))
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "unknown webhook kind"))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// generateWebhookSecret creates a new webhook secret in format "wh_<base32(24 bytes)>".
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// ---- stub WebhookCallStore for admin tests ----
|
||||
|
||||
type adminCallStore struct {
|
||||
mu sync.Mutex
|
||||
rows []store.WebhookCallData
|
||||
calls int // List invocation counter
|
||||
}
|
||||
|
||||
func (s *adminCallStore) Create(_ context.Context, c *store.WebhookCallData) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.rows = append(s.rows, *c)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *adminCallStore) List(ctx context.Context, f store.WebhookCallListFilter) ([]store.WebhookCallData, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.calls++
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
var out []store.WebhookCallData
|
||||
for _, r := range s.rows {
|
||||
// Mirror real store tenant scoping.
|
||||
if tid != uuid.Nil && r.TenantID != tid && !store.IsOwnerRole(ctx) {
|
||||
continue
|
||||
}
|
||||
if f.WebhookID != nil && r.WebhookID != *f.WebhookID {
|
||||
continue
|
||||
}
|
||||
if f.Status != "" && r.Status != f.Status {
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *adminCallStore) GetByID(ctx context.Context, id uuid.UUID) (*store.WebhookCallData, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
for i := range s.rows {
|
||||
if s.rows[i].ID != id {
|
||||
continue
|
||||
}
|
||||
if tid != uuid.Nil && s.rows[i].TenantID != tid && !store.IsOwnerRole(ctx) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
cp := s.rows[i]
|
||||
return &cp, nil
|
||||
}
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
func (s *adminCallStore) GetByIdempotency(context.Context, uuid.UUID, string) (*store.WebhookCallData, error) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
func (s *adminCallStore) UpdateStatus(context.Context, uuid.UUID, map[string]any) error { return nil }
|
||||
func (s *adminCallStore) UpdateStatusCAS(context.Context, uuid.UUID, string, map[string]any) error {
|
||||
return nil
|
||||
}
|
||||
func (s *adminCallStore) ClaimNext(context.Context, uuid.UUID, time.Time) (*store.WebhookCallData, error) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
func (s *adminCallStore) DeleteOlderThan(context.Context, uuid.UUID, time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (s *adminCallStore) ReclaimStale(context.Context, time.Time) (int64, error) { return 0, nil }
|
||||
|
||||
// ---- stub testers ----
|
||||
|
||||
type stubLLMTester struct {
|
||||
resp *webhookLLMSyncResp
|
||||
err error
|
||||
gotWh *store.WebhookData
|
||||
gotIn string
|
||||
gotMdl string
|
||||
invoked bool
|
||||
}
|
||||
|
||||
func (s *stubLLMTester) RunTest(_ context.Context, wh *store.WebhookData, input, model string) (*webhookLLMSyncResp, error) {
|
||||
s.invoked = true
|
||||
s.gotWh = wh
|
||||
s.gotIn = input
|
||||
s.gotMdl = model
|
||||
return s.resp, s.err
|
||||
}
|
||||
|
||||
type stubMsgTester struct {
|
||||
resp *webhookMessageResp
|
||||
err error
|
||||
invoked bool
|
||||
}
|
||||
|
||||
func (s *stubMsgTester) RunTest(_ context.Context, _ *store.WebhookData, _ webhookMessageReq) (*webhookMessageResp, error) {
|
||||
s.invoked = true
|
||||
return s.resp, s.err
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func newAdminHandlerWithCalls(ws *adminWebhookStore, cs store.WebhookCallStore, ts *adminTenantStore) *WebhooksAdminHandler {
|
||||
h := NewWebhooksAdminHandler(ws, cs, ts, nil)
|
||||
h.SetEncKey(testAdminEncKey)
|
||||
return h
|
||||
}
|
||||
|
||||
// ---- list-calls tests ----
|
||||
|
||||
func TestWebhookAdmin_ListCalls_Success(t *testing.T) {
|
||||
tid := uuid.New()
|
||||
wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "llm"}
|
||||
cs := &adminCallStore{}
|
||||
now := time.Now()
|
||||
done := "done"
|
||||
cs.rows = []store.WebhookCallData{
|
||||
{ID: uuid.New(), TenantID: tid, WebhookID: wh.ID, DeliveryID: uuid.New(), Mode: "sync", Status: "done", Attempts: 1, CreatedAt: now, Response: []byte(`{"output":"hi"}`)},
|
||||
{ID: uuid.New(), TenantID: tid, WebhookID: wh.ID, DeliveryID: uuid.New(), Mode: "async", Status: "failed", Attempts: 3, CreatedAt: now, LastError: &done},
|
||||
{ID: uuid.New(), TenantID: uuid.New(), WebhookID: uuid.New(), DeliveryID: uuid.New(), Mode: "sync", Status: "done", CreatedAt: now}, // other tenant/webhook
|
||||
}
|
||||
h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), cs, &adminTenantStore{roles: map[string]string{tid.String() + ":admin-user": "admin"}})
|
||||
|
||||
ctx := webhookTenantAdminCtx(tid, "admin-user")
|
||||
w := doRequest(t, h, http.MethodGet, "/v1/webhooks/"+wh.ID.String()+"/calls", nil, ctx)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var got []webhookCallResp
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 calls for this webhook, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookAdmin_ListCalls_StatusFilter(t *testing.T) {
|
||||
tid := uuid.New()
|
||||
wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "llm"}
|
||||
cs := &adminCallStore{}
|
||||
now := time.Now()
|
||||
cs.rows = []store.WebhookCallData{
|
||||
{ID: uuid.New(), TenantID: tid, WebhookID: wh.ID, Status: "done", CreatedAt: now},
|
||||
{ID: uuid.New(), TenantID: tid, WebhookID: wh.ID, Status: "failed", CreatedAt: now},
|
||||
}
|
||||
h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), cs, &adminTenantStore{roles: map[string]string{tid.String() + ":admin-user": "admin"}})
|
||||
|
||||
ctx := webhookTenantAdminCtx(tid, "admin-user")
|
||||
w := doRequest(t, h, http.MethodGet, "/v1/webhooks/"+wh.ID.String()+"/calls?status=failed", nil, ctx)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d", w.Code)
|
||||
}
|
||||
var got []webhookCallResp
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||||
if len(got) != 1 || got[0].Status != "failed" {
|
||||
t.Fatalf("status filter failed: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookAdmin_ListCalls_TenantIsolation(t *testing.T) {
|
||||
ownerTid := uuid.New()
|
||||
otherTid := uuid.New()
|
||||
wh := &store.WebhookData{ID: uuid.New(), TenantID: ownerTid, Name: "wh", Kind: "llm"}
|
||||
cs := &adminCallStore{}
|
||||
h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), cs, &adminTenantStore{roles: map[string]string{
|
||||
ownerTid.String() + ":owner-user": "admin",
|
||||
otherTid.String() + ":other": "admin", // valid admin of a different tenant
|
||||
}})
|
||||
|
||||
// A different tenant's admin must not see the webhook (tenant-scoped GetByID → 404).
|
||||
ctx := webhookTenantAdminCtx(otherTid, "other")
|
||||
w := doRequest(t, h, http.MethodGet, "/v1/webhooks/"+wh.ID.String()+"/calls", nil, ctx)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("cross-tenant must get 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookAdmin_ListCalls_NilStore503(t *testing.T) {
|
||||
tid := uuid.New()
|
||||
wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "llm"}
|
||||
h := newAdminHandler(newAdminWebhookStore(wh), &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}}) // calls == nil
|
||||
ctx := webhookTenantAdminCtx(tid, "u")
|
||||
w := doRequest(t, h, http.MethodGet, "/v1/webhooks/"+wh.ID.String()+"/calls", nil, ctx)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("nil call store must get 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- get-call (detail) tests ----
|
||||
|
||||
func TestWebhookAdmin_GetCall_Success(t *testing.T) {
|
||||
tid := uuid.New()
|
||||
wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "llm"}
|
||||
callID := uuid.New()
|
||||
cb := "https://example.com/cb"
|
||||
cs := &adminCallStore{}
|
||||
cs.rows = []store.WebhookCallData{{
|
||||
ID: callID, TenantID: tid, WebhookID: wh.ID, DeliveryID: uuid.New(), Mode: "async",
|
||||
Status: "done", Attempts: 1, CallbackURL: &cb, CreatedAt: time.Now(),
|
||||
RequestPayload: []byte(`{"body_hash":"abc","meta":{}}`), Response: []byte(`{"output":"ok"}`),
|
||||
}}
|
||||
h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), cs, &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}})
|
||||
|
||||
ctx := webhookTenantAdminCtx(tid, "u")
|
||||
w := doRequest(t, h, http.MethodGet, "/v1/webhooks/"+wh.ID.String()+"/calls/"+callID.String(), nil, ctx)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var got webhookCallDetailResp
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.ID != callID || got.RequestPayload == "" || got.Response == "" || got.CallbackURL == nil {
|
||||
t.Fatalf("detail missing fields: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookAdmin_GetCall_WrongWebhook404(t *testing.T) {
|
||||
tid := uuid.New()
|
||||
wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "llm"}
|
||||
other := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "other", Kind: "llm"}
|
||||
callID := uuid.New()
|
||||
cs := &adminCallStore{}
|
||||
// Call belongs to `other`, not `wh`.
|
||||
cs.rows = []store.WebhookCallData{{ID: callID, TenantID: tid, WebhookID: other.ID, CreatedAt: time.Now()}}
|
||||
h := newAdminHandlerWithCalls(newAdminWebhookStore(wh, other), cs, &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}})
|
||||
|
||||
ctx := webhookTenantAdminCtx(tid, "u")
|
||||
w := doRequest(t, h, http.MethodGet, "/v1/webhooks/"+wh.ID.String()+"/calls/"+callID.String(), nil, ctx)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("call of another webhook must be 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookAdmin_GetCall_TenantIsolation(t *testing.T) {
|
||||
ownerTid := uuid.New()
|
||||
otherTid := uuid.New()
|
||||
wh := &store.WebhookData{ID: uuid.New(), TenantID: ownerTid, Name: "wh", Kind: "llm"}
|
||||
callID := uuid.New()
|
||||
cs := &adminCallStore{}
|
||||
cs.rows = []store.WebhookCallData{{ID: callID, TenantID: ownerTid, WebhookID: wh.ID, CreatedAt: time.Now()}}
|
||||
h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), cs, &adminTenantStore{roles: map[string]string{
|
||||
ownerTid.String() + ":owner": "admin",
|
||||
otherTid.String() + ":other": "admin",
|
||||
}})
|
||||
|
||||
ctx := webhookTenantAdminCtx(otherTid, "other")
|
||||
w := doRequest(t, h, http.MethodGet, "/v1/webhooks/"+wh.ID.String()+"/calls/"+callID.String(), nil, ctx)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("cross-tenant must be 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- test-endpoint tests ----
|
||||
|
||||
func TestWebhookAdmin_Test_LLM_Success(t *testing.T) {
|
||||
tid := uuid.New()
|
||||
agentID := uuid.New()
|
||||
wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "llm", AgentID: &agentID}
|
||||
cs := &adminCallStore{}
|
||||
h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), cs, &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}})
|
||||
llm := &stubLLMTester{resp: &webhookLLMSyncResp{CallID: "c1", AgentID: agentID.String(), Output: "pong", FinishReason: "stop"}}
|
||||
h.llmTester = llm
|
||||
|
||||
ctx := webhookTenantAdminCtx(tid, "u")
|
||||
w := doRequest(t, h, http.MethodPost, "/v1/webhooks/"+wh.ID.String()+"/test", map[string]any{"input": "ping"}, ctx)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !llm.invoked || llm.gotIn != "ping" {
|
||||
t.Fatalf("tester not invoked correctly: %+v", llm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookAdmin_Test_LLM_MissingInput(t *testing.T) {
|
||||
tid := uuid.New()
|
||||
agentID := uuid.New()
|
||||
wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "llm", AgentID: &agentID}
|
||||
h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), &adminCallStore{}, &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}})
|
||||
h.llmTester = &stubLLMTester{}
|
||||
ctx := webhookTenantAdminCtx(tid, "u")
|
||||
w := doRequest(t, h, http.MethodPost, "/v1/webhooks/"+wh.ID.String()+"/test", map[string]any{}, ctx)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing input must be 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookAdmin_Test_LLM_RunError(t *testing.T) {
|
||||
tid := uuid.New()
|
||||
agentID := uuid.New()
|
||||
wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "llm", AgentID: &agentID}
|
||||
h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), &adminCallStore{}, &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}})
|
||||
h.llmTester = &stubLLMTester{err: errors.New("boom")}
|
||||
ctx := webhookTenantAdminCtx(tid, "u")
|
||||
w := doRequest(t, h, http.MethodPost, "/v1/webhooks/"+wh.ID.String()+"/test", map[string]any{"input": "x"}, ctx)
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Fatalf("run error must be 502, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookAdmin_Test_Message_RequiresStandard(t *testing.T) {
|
||||
tid := uuid.New()
|
||||
wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "message"}
|
||||
h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), &adminCallStore{}, &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}})
|
||||
// msgTester left nil → message test must be rejected.
|
||||
ctx := webhookTenantAdminCtx(tid, "u")
|
||||
w := doRequest(t, h, http.MethodPost, "/v1/webhooks/"+wh.ID.String()+"/test", map[string]any{"chat_id": "123", "content": "hi"}, ctx)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("nil msgTester must be 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookAdmin_Test_Message_Success(t *testing.T) {
|
||||
tid := uuid.New()
|
||||
wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "message"}
|
||||
h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), &adminCallStore{}, &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}})
|
||||
msg := &stubMsgTester{resp: &webhookMessageResp{CallID: "c1", Status: "sent", ChannelName: "tg", ChatID: "123"}}
|
||||
h.msgTester = msg
|
||||
ctx := webhookTenantAdminCtx(tid, "u")
|
||||
w := doRequest(t, h, http.MethodPost, "/v1/webhooks/"+wh.ID.String()+"/test", map[string]any{"channel_name": "tg", "chat_id": "123", "content": "hi"}, ctx)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !msg.invoked {
|
||||
t.Fatal("msg tester not invoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookAdmin_Test_Message_MissingChannelName(t *testing.T) {
|
||||
tid := uuid.New()
|
||||
wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "message"}
|
||||
h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), &adminCallStore{}, &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}})
|
||||
msg := &stubMsgTester{resp: &webhookMessageResp{CallID: "c1", Status: "sent", ChannelName: "tg", ChatID: "123"}}
|
||||
h.msgTester = msg
|
||||
ctx := webhookTenantAdminCtx(tid, "u")
|
||||
w := doRequest(t, h, http.MethodPost, "/v1/webhooks/"+wh.ID.String()+"/test", map[string]any{"chat_id": "123", "content": "hi"}, ctx)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing channel_name must be 400, got %d", w.Code)
|
||||
}
|
||||
if msg.invoked {
|
||||
t.Fatal("msg tester must not run when channel_name is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookAdmin_Test_Message_MissingContent(t *testing.T) {
|
||||
tid := uuid.New()
|
||||
wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "message"}
|
||||
h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), &adminCallStore{}, &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}})
|
||||
msg := &stubMsgTester{resp: &webhookMessageResp{CallID: "c1", Status: "sent", ChannelName: "tg", ChatID: "123"}}
|
||||
h.msgTester = msg
|
||||
ctx := webhookTenantAdminCtx(tid, "u")
|
||||
w := doRequest(t, h, http.MethodPost, "/v1/webhooks/"+wh.ID.String()+"/test", map[string]any{"channel_name": "tg", "chat_id": "123"}, ctx)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing content must be 400, got %d", w.Code)
|
||||
}
|
||||
if msg.invoked {
|
||||
t.Fatal("msg tester must not run when content is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookAdmin_Test_Revoked409(t *testing.T) {
|
||||
tid := uuid.New()
|
||||
agentID := uuid.New()
|
||||
wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "llm", AgentID: &agentID, Revoked: true}
|
||||
h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), &adminCallStore{}, &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}})
|
||||
h.llmTester = &stubLLMTester{}
|
||||
ctx := webhookTenantAdminCtx(tid, "u")
|
||||
w := doRequest(t, h, http.MethodPost, "/v1/webhooks/"+wh.ID.String()+"/test", map[string]any{"input": "x"}, ctx)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("revoked webhook test must be 409, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -222,7 +222,7 @@ func webhookTenantCtxWithRole(tenantID uuid.UUID, userID, role string) context.C
|
||||
const testAdminEncKey = "00000000000000000000000000000000"
|
||||
|
||||
func newAdminHandler(ws *adminWebhookStore, ts *adminTenantStore) *WebhooksAdminHandler {
|
||||
h := NewWebhooksAdminHandler(ws, ts, nil)
|
||||
h := NewWebhooksAdminHandler(ws, nil, ts, nil)
|
||||
h.SetEncKey(testAdminEncKey) // required since K6 guard rejects empty encKey
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -511,6 +511,137 @@ func (h *WebhookLLMHandler) handleAsync(
|
||||
})
|
||||
}
|
||||
|
||||
// RunTest performs a synchronous test invocation of an llm-kind webhook on behalf of an
|
||||
// admin (POST /v1/webhooks/{id}/test). The caller (WebhooksAdminHandler) has already
|
||||
// authorized the request and verified webhook ownership — no webhook secret is involved.
|
||||
// It resolves the bound agent, runs it within the standard sync timeout, writes an audit
|
||||
// row (mode=sync), and returns the response. Errors are returned (not written to HTTP).
|
||||
func (h *WebhookLLMHandler) RunTest(ctx context.Context, wh *store.WebhookData, input, model string) (*webhookLLMSyncResp, error) {
|
||||
if wh == nil || wh.AgentID == nil {
|
||||
return nil, errors.New("webhook has no bound agent")
|
||||
}
|
||||
agentID := wh.AgentID.String()
|
||||
|
||||
ag, agErr := h.agentRouter.Get(ctx, agentID)
|
||||
if agErr != nil {
|
||||
return nil, fmt.Errorf("agent not found: %w", agErr)
|
||||
}
|
||||
// P0 cross-tenant isolation: agent must belong to webhook's tenant.
|
||||
if ag.UUID() != *wh.AgentID {
|
||||
slog.Warn("security.webhook.tenant_mismatch", "webhook_id", wh.ID, "webhook_tenant", wh.TenantID, "agent_id", agentID)
|
||||
return nil, errors.New("agent tenant mismatch")
|
||||
}
|
||||
|
||||
callID := store.GenNewID()
|
||||
deliveryID := store.GenNewID()
|
||||
now := time.Now()
|
||||
runID := uuid.NewString()
|
||||
sessionKey := resolveWebhookSessionKey("", agentID, wh.ID, runID)
|
||||
|
||||
requestPayload, _ := buildAuditPayload(nil, map[string]any{"test": true, "input_len": len(input)})
|
||||
callRecord := &store.WebhookCallData{
|
||||
ID: callID,
|
||||
TenantID: wh.TenantID,
|
||||
WebhookID: wh.ID,
|
||||
AgentID: wh.AgentID,
|
||||
DeliveryID: deliveryID,
|
||||
Mode: "sync",
|
||||
Status: "running",
|
||||
RequestPayload: requestPayload,
|
||||
CreatedAt: now,
|
||||
StartedAt: &now,
|
||||
}
|
||||
|
||||
rr := agent.RunRequest{
|
||||
SessionKey: sessionKey,
|
||||
Message: input,
|
||||
Channel: "webhook",
|
||||
ChatID: wh.ID.String(),
|
||||
RunID: runID,
|
||||
Stream: false,
|
||||
ModelOverride: model,
|
||||
TraceName: "webhook.llm.test",
|
||||
TraceTags: []string{"webhook", "test"},
|
||||
}
|
||||
|
||||
timeout := webhookLLMTimeout
|
||||
if h.syncTimeout > 0 {
|
||||
timeout = h.syncTimeout
|
||||
}
|
||||
|
||||
type runOutcome struct {
|
||||
result *agent.RunResult
|
||||
err error
|
||||
}
|
||||
outCh := make(chan runOutcome, 1)
|
||||
|
||||
laneCtx, laneCancel := context.WithTimeout(ctx, timeout)
|
||||
defer laneCancel()
|
||||
|
||||
submitErr := h.lane.Submit(laneCtx, func() {
|
||||
runCtx, runCancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
|
||||
defer runCancel()
|
||||
result, err := ag.Run(runCtx, rr)
|
||||
outCh <- runOutcome{result: result, err: err}
|
||||
})
|
||||
if submitErr != nil {
|
||||
completedAt := time.Now()
|
||||
errMsg := submitErr.Error()
|
||||
callRecord.Status = "failed"
|
||||
callRecord.Attempts = 1
|
||||
callRecord.CompletedAt = &completedAt
|
||||
callRecord.LastError = &errMsg
|
||||
persistWebhookCall(ctx, h.callStore, callRecord, false, "webhook.llm.test_audit_write_failed")
|
||||
return nil, fmt.Errorf("webhook lane saturated: %w", submitErr)
|
||||
}
|
||||
|
||||
var out runOutcome
|
||||
select {
|
||||
case out = <-outCh:
|
||||
case <-laneCtx.Done():
|
||||
out = runOutcome{err: context.DeadlineExceeded}
|
||||
}
|
||||
|
||||
if out.err != nil {
|
||||
completedAt := time.Now()
|
||||
errMsg := out.err.Error()
|
||||
callRecord.Status = "failed"
|
||||
callRecord.Attempts = 1
|
||||
callRecord.CompletedAt = &completedAt
|
||||
callRecord.LastError = &errMsg
|
||||
persistWebhookCall(ctx, h.callStore, callRecord, false, "webhook.llm.test_audit_write_failed")
|
||||
return nil, out.err
|
||||
}
|
||||
|
||||
resp := &webhookLLMSyncResp{
|
||||
CallID: callID.String(),
|
||||
AgentID: agentID,
|
||||
Output: out.result.Content,
|
||||
FinishReason: "stop",
|
||||
}
|
||||
if out.result.Usage != nil {
|
||||
resp.Usage = &webhookLLMUsage{
|
||||
PromptTokens: out.result.Usage.PromptTokens,
|
||||
CompletionTokens: out.result.Usage.CompletionTokens,
|
||||
TotalTokens: out.result.Usage.TotalTokens,
|
||||
}
|
||||
}
|
||||
|
||||
respBytes, _ := json.Marshal(resp)
|
||||
if len(respBytes) > webhookLLMResponseTruncate {
|
||||
respBytes = respBytes[:webhookLLMResponseTruncate]
|
||||
}
|
||||
completedAt := time.Now()
|
||||
callRecord.Status = "done"
|
||||
callRecord.Attempts = 1
|
||||
callRecord.Response = respBytes
|
||||
callRecord.CompletedAt = &completedAt
|
||||
persistWebhookCall(ctx, h.callStore, callRecord, false, "webhook.llm.test_audit_write_failed")
|
||||
|
||||
slog.Info("webhook.llm.test", "call_id", callID, "agent_id", agentID, "webhook_id", wh.ID, "output_len", len(out.result.Content))
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// buildInput parses the raw JSON input into a user message and optional extra system prompt.
|
||||
//
|
||||
// Two formats are accepted:
|
||||
|
||||
@@ -203,6 +203,129 @@ func (h *WebhookMessageHandler) handle(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, respBody)
|
||||
}
|
||||
|
||||
// RunTest performs a test message send for a message-kind webhook on behalf of an admin
|
||||
// (POST /v1/webhooks/{id}/test). The caller has already authorized + verified ownership.
|
||||
// It resolves + tenant-validates the channel, sends the message (real side effect), writes
|
||||
// an audit row (mode=sync), and returns the response. Errors are returned (not written to HTTP).
|
||||
func (h *WebhookMessageHandler) RunTest(ctx context.Context, wh *store.WebhookData, req webhookMessageReq) (*webhookMessageResp, error) {
|
||||
if wh == nil {
|
||||
return nil, errors.New("webhook missing")
|
||||
}
|
||||
|
||||
// Resolve channel: bound channel_id takes precedence over request channel_name.
|
||||
var channelName string
|
||||
if wh.ChannelID != nil {
|
||||
inst, err := h.channelInstances.Get(ctx, *wh.ChannelID)
|
||||
if err != nil || inst == nil {
|
||||
return nil, errors.New("bound channel not found")
|
||||
}
|
||||
channelName = inst.Name
|
||||
} else {
|
||||
if req.ChannelName == "" {
|
||||
return nil, errors.New("channel_name required")
|
||||
}
|
||||
channelName = req.ChannelName
|
||||
}
|
||||
|
||||
// P0 cross-tenant isolation: channel must belong to webhook's tenant.
|
||||
channelTenantID, exists := h.channelMgr.ChannelTenantID(channelName)
|
||||
if !exists {
|
||||
return nil, errors.New("channel not found")
|
||||
}
|
||||
if channelTenantID != uuid.Nil && channelTenantID != wh.TenantID {
|
||||
slog.Warn("security.webhook.tenant_leak_attempt", "webhook_id", wh.ID, "webhook_tenant", wh.TenantID,
|
||||
"channel_name", channelName, "channel_tenant", channelTenantID)
|
||||
return nil, errors.New("channel tenant mismatch")
|
||||
}
|
||||
|
||||
if req.ChatID == "" {
|
||||
return nil, errors.New("chat_id required")
|
||||
}
|
||||
if req.Content == "" && req.MediaURL == "" {
|
||||
return nil, errors.New("content or media_url required")
|
||||
}
|
||||
if len(req.Content) > webhookContentMaxBytes {
|
||||
return nil, errors.New("content exceeds 16 KB limit")
|
||||
}
|
||||
|
||||
callID := store.GenNewID()
|
||||
deliveryID := store.GenNewID()
|
||||
now := time.Now()
|
||||
requestPayload, _ := buildAuditPayload(nil, map[string]any{
|
||||
"test": true, "channel_name": channelName, "chat_id": req.ChatID, "has_media": req.MediaURL != "",
|
||||
})
|
||||
callRecord := &store.WebhookCallData{
|
||||
ID: callID,
|
||||
TenantID: wh.TenantID,
|
||||
WebhookID: wh.ID,
|
||||
AgentID: wh.AgentID,
|
||||
DeliveryID: deliveryID,
|
||||
Mode: "sync",
|
||||
Status: "running",
|
||||
StartedAt: &now,
|
||||
RequestPayload: requestPayload,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
warning, sendErr := h.dispatchTest(ctx, wh, req, channelName)
|
||||
if sendErr != nil {
|
||||
h.failCall(ctx, callRecord, false, sendErr.Error())
|
||||
return nil, sendErr
|
||||
}
|
||||
|
||||
completedAt := time.Now()
|
||||
callRecord.Status = "done"
|
||||
callRecord.CompletedAt = &completedAt
|
||||
callRecord.Attempts = 1
|
||||
resp := &webhookMessageResp{
|
||||
CallID: callID.String(),
|
||||
Status: "sent",
|
||||
ChannelName: channelName,
|
||||
ChatID: req.ChatID,
|
||||
Warning: warning,
|
||||
}
|
||||
respBytes, _ := json.Marshal(resp)
|
||||
callRecord.Response = respBytes
|
||||
persistWebhookCall(ctx, h.callStore, callRecord, false, "webhook.message.test_audit_write_failed")
|
||||
|
||||
slog.Info("webhook.message.test", "tenant_id", wh.TenantID, "webhook_id", wh.ID,
|
||||
"channel", channelName, "chat_id", req.ChatID, "has_media", req.MediaURL != "")
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// dispatchTest sends a test message (text or media) and returns (warning, error).
|
||||
// Mirrors dispatch but returns errors instead of writing HTTP responses.
|
||||
func (h *WebhookMessageHandler) dispatchTest(ctx context.Context, wh *store.WebhookData, req webhookMessageReq, channelName string) (string, error) {
|
||||
if req.MediaURL == "" {
|
||||
if err := h.channelMgr.SendToChannel(ctx, channelName, req.ChatID, req.Content); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
probe, probeErr := probeMediaURL(req.MediaURL)
|
||||
if probeErr != nil {
|
||||
return "", probeErr
|
||||
}
|
||||
|
||||
channelType := h.channelMgr.ChannelTypeForName(channelName)
|
||||
if channels.IsMediaCapable(channelType) {
|
||||
media := []bus.MediaAttachment{{URL: req.MediaURL, ContentType: probe.ContentType, Caption: req.MediaCaption}}
|
||||
if err := h.channelMgr.SendMediaToChannel(ctx, channelName, req.ChatID, req.Content, media); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if req.FallbackToText {
|
||||
if err := h.channelMgr.SendToChannel(ctx, channelName, req.ChatID, req.Content); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "media_not_supported_fallback_text", nil
|
||||
}
|
||||
return "", errors.New("channel does not support media and fallback_to_text is false")
|
||||
}
|
||||
|
||||
// dispatch sends the message (media or text) to the channel.
|
||||
// Returns (warning string, error). On non-nil error the response was already written.
|
||||
func (h *WebhookMessageHandler) dispatch(
|
||||
|
||||
+23
-22
@@ -267,28 +267,29 @@ func init() {
|
||||
MsgBackendNotReady: "workstation backend not ready: %s",
|
||||
|
||||
// Webhooks
|
||||
MsgWebhookAuthFailed: "webhook authentication failed",
|
||||
MsgWebhookHMACInvalid: "HMAC signature is invalid",
|
||||
MsgWebhookHMACTimestampSkew: "request timestamp outside acceptable window",
|
||||
MsgWebhookBearerRequiredHMAC: "this webhook requires HMAC authentication",
|
||||
MsgWebhookRevoked: "webhook has been revoked",
|
||||
MsgWebhookKindMismatch: "request kind does not match webhook configuration",
|
||||
MsgWebhookRateLimited: "webhook rate limit exceeded",
|
||||
MsgWebhookBodyTooLarge: "request body exceeds size limit",
|
||||
MsgWebhookIdempotencyConflict: "idempotency key conflict: request body mismatch",
|
||||
MsgWebhookTenantMismatch: "webhook tenant mismatch",
|
||||
MsgWebhookAgentNotFound: "webhook agent not found",
|
||||
MsgWebhookChannelNotFound: "webhook channel not found",
|
||||
MsgWebhookMediaSSRFBlocked: "media URL blocked by SSRF policy",
|
||||
MsgWebhookMediaTooLarge: "media file exceeds size limit",
|
||||
MsgWebhookMediaMIMEDenied: "media MIME type is not allowed",
|
||||
MsgWebhookCallbackURLInvalid: "callback URL is invalid or blocked",
|
||||
MsgWebhookLLMTimeout: "LLM processing timed out",
|
||||
MsgWebhookLaneSaturated: "webhook processing lane is at capacity",
|
||||
MsgWebhookLocalhostOnlyViolation: "this webhook is restricted to localhost callers",
|
||||
MsgWebhookMediaChannelUnsupported: "channel does not support media attachments",
|
||||
MsgWebhookIPDenied: "request origin is not in the IP allowlist",
|
||||
MsgWebhookEncryptionUnavailable: "webhook encryption key not configured; set GOCLAW_ENCRYPTION_KEY to enable webhooks",
|
||||
MsgWebhookAuthFailed: "webhook authentication failed",
|
||||
MsgWebhookHMACInvalid: "HMAC signature is invalid",
|
||||
MsgWebhookHMACTimestampSkew: "request timestamp outside acceptable window",
|
||||
MsgWebhookBearerRequiredHMAC: "this webhook requires HMAC authentication",
|
||||
MsgWebhookRevoked: "webhook has been revoked",
|
||||
MsgWebhookKindMismatch: "request kind does not match webhook configuration",
|
||||
MsgWebhookRateLimited: "webhook rate limit exceeded",
|
||||
MsgWebhookBodyTooLarge: "request body exceeds size limit",
|
||||
MsgWebhookIdempotencyConflict: "idempotency key conflict: request body mismatch",
|
||||
MsgWebhookTenantMismatch: "webhook tenant mismatch",
|
||||
MsgWebhookAgentNotFound: "webhook agent not found",
|
||||
MsgWebhookChannelNotFound: "webhook channel not found",
|
||||
MsgWebhookMediaSSRFBlocked: "media URL blocked by SSRF policy",
|
||||
MsgWebhookMediaTooLarge: "media file exceeds size limit",
|
||||
MsgWebhookMediaMIMEDenied: "media MIME type is not allowed",
|
||||
MsgWebhookCallbackURLInvalid: "callback URL is invalid or blocked",
|
||||
MsgWebhookLLMTimeout: "LLM processing timed out",
|
||||
MsgWebhookLaneSaturated: "webhook processing lane is at capacity",
|
||||
MsgWebhookLocalhostOnlyViolation: "this webhook is restricted to localhost callers",
|
||||
MsgWebhookMediaChannelUnsupported: "channel does not support media attachments",
|
||||
MsgWebhookIPDenied: "request origin is not in the IP allowlist",
|
||||
MsgWebhookEncryptionUnavailable: "webhook encryption key not configured; set GOCLAW_ENCRYPTION_KEY to enable webhooks",
|
||||
MsgWebhookMessageTestRequiresStandard: "testing message webhooks requires Standard edition",
|
||||
|
||||
// Hooks
|
||||
MsgHookInvalidMatcher: "invalid matcher regex: %s",
|
||||
|
||||
+23
-22
@@ -257,28 +257,29 @@ func init() {
|
||||
MsgVoiceMessageFallback: "[Tin nhắn thoại]",
|
||||
|
||||
// Webhooks
|
||||
MsgWebhookAuthFailed: "xác thực webhook thất bại",
|
||||
MsgWebhookHMACInvalid: "chữ ký HMAC không hợp lệ",
|
||||
MsgWebhookHMACTimestampSkew: "thời gian yêu cầu nằm ngoài cửa sổ chấp nhận",
|
||||
MsgWebhookBearerRequiredHMAC: "webhook này yêu cầu xác thực HMAC",
|
||||
MsgWebhookRevoked: "webhook đã bị thu hồi",
|
||||
MsgWebhookKindMismatch: "loại yêu cầu không khớp cấu hình webhook",
|
||||
MsgWebhookRateLimited: "vượt quá giới hạn tốc độ webhook",
|
||||
MsgWebhookBodyTooLarge: "nội dung yêu cầu vượt quá giới hạn kích thước",
|
||||
MsgWebhookIdempotencyConflict: "xung đột idempotency key: nội dung yêu cầu không khớp",
|
||||
MsgWebhookTenantMismatch: "tenant của webhook không khớp",
|
||||
MsgWebhookAgentNotFound: "không tìm thấy agent webhook",
|
||||
MsgWebhookChannelNotFound: "không tìm thấy kênh webhook",
|
||||
MsgWebhookMediaSSRFBlocked: "URL media bị chặn bởi chính sách SSRF",
|
||||
MsgWebhookMediaTooLarge: "tệp media vượt quá giới hạn kích thước",
|
||||
MsgWebhookMediaMIMEDenied: "loại MIME của media không được phép",
|
||||
MsgWebhookCallbackURLInvalid: "URL callback không hợp lệ hoặc bị chặn",
|
||||
MsgWebhookLLMTimeout: "LLM xử lý hết thời gian chờ",
|
||||
MsgWebhookLaneSaturated: "làn xử lý webhook đã đầy",
|
||||
MsgWebhookLocalhostOnlyViolation: "webhook này chỉ cho phép gọi từ localhost",
|
||||
MsgWebhookMediaChannelUnsupported: "kênh không hỗ trợ tệp đính kèm media",
|
||||
MsgWebhookIPDenied: "địa chỉ IP không nằm trong danh sách cho phép",
|
||||
MsgWebhookEncryptionUnavailable: "khóa mã hóa webhook chưa được cấu hình; hãy đặt GOCLAW_ENCRYPTION_KEY để kích hoạt webhook",
|
||||
MsgWebhookAuthFailed: "xác thực webhook thất bại",
|
||||
MsgWebhookHMACInvalid: "chữ ký HMAC không hợp lệ",
|
||||
MsgWebhookHMACTimestampSkew: "thời gian yêu cầu nằm ngoài cửa sổ chấp nhận",
|
||||
MsgWebhookBearerRequiredHMAC: "webhook này yêu cầu xác thực HMAC",
|
||||
MsgWebhookRevoked: "webhook đã bị thu hồi",
|
||||
MsgWebhookKindMismatch: "loại yêu cầu không khớp cấu hình webhook",
|
||||
MsgWebhookRateLimited: "vượt quá giới hạn tốc độ webhook",
|
||||
MsgWebhookBodyTooLarge: "nội dung yêu cầu vượt quá giới hạn kích thước",
|
||||
MsgWebhookIdempotencyConflict: "xung đột idempotency key: nội dung yêu cầu không khớp",
|
||||
MsgWebhookTenantMismatch: "tenant của webhook không khớp",
|
||||
MsgWebhookAgentNotFound: "không tìm thấy agent webhook",
|
||||
MsgWebhookChannelNotFound: "không tìm thấy kênh webhook",
|
||||
MsgWebhookMediaSSRFBlocked: "URL media bị chặn bởi chính sách SSRF",
|
||||
MsgWebhookMediaTooLarge: "tệp media vượt quá giới hạn kích thước",
|
||||
MsgWebhookMediaMIMEDenied: "loại MIME của media không được phép",
|
||||
MsgWebhookCallbackURLInvalid: "URL callback không hợp lệ hoặc bị chặn",
|
||||
MsgWebhookLLMTimeout: "LLM xử lý hết thời gian chờ",
|
||||
MsgWebhookLaneSaturated: "làn xử lý webhook đã đầy",
|
||||
MsgWebhookLocalhostOnlyViolation: "webhook này chỉ cho phép gọi từ localhost",
|
||||
MsgWebhookMediaChannelUnsupported: "kênh không hỗ trợ tệp đính kèm media",
|
||||
MsgWebhookIPDenied: "địa chỉ IP không nằm trong danh sách cho phép",
|
||||
MsgWebhookEncryptionUnavailable: "khóa mã hóa webhook chưa được cấu hình; hãy đặt GOCLAW_ENCRYPTION_KEY để kích hoạt webhook",
|
||||
MsgWebhookMessageTestRequiresStandard: "kiểm thử webhook loại message yêu cầu bản Standard",
|
||||
|
||||
// Hooks
|
||||
// Workstation
|
||||
|
||||
+23
-22
@@ -257,28 +257,29 @@ func init() {
|
||||
MsgVoiceMessageFallback: "[语音消息]",
|
||||
|
||||
// Webhooks
|
||||
MsgWebhookAuthFailed: "Webhook 身份验证失败",
|
||||
MsgWebhookHMACInvalid: "HMAC 签名无效",
|
||||
MsgWebhookHMACTimestampSkew: "请求时间戳超出可接受窗口",
|
||||
MsgWebhookBearerRequiredHMAC: "此 Webhook 需要 HMAC 身份验证",
|
||||
MsgWebhookRevoked: "Webhook 已被撤销",
|
||||
MsgWebhookKindMismatch: "请求类型与 Webhook 配置不匹配",
|
||||
MsgWebhookRateLimited: "超出 Webhook 速率限制",
|
||||
MsgWebhookBodyTooLarge: "请求正文超出大小限制",
|
||||
MsgWebhookIdempotencyConflict: "幂等键冲突:请求正文不匹配",
|
||||
MsgWebhookTenantMismatch: "Webhook 租户不匹配",
|
||||
MsgWebhookAgentNotFound: "未找到 Webhook 代理",
|
||||
MsgWebhookChannelNotFound: "未找到 Webhook 频道",
|
||||
MsgWebhookMediaSSRFBlocked: "媒体 URL 被 SSRF 策略拦截",
|
||||
MsgWebhookMediaTooLarge: "媒体文件超出大小限制",
|
||||
MsgWebhookMediaMIMEDenied: "媒体 MIME 类型不被允许",
|
||||
MsgWebhookCallbackURLInvalid: "回调 URL 无效或被拦截",
|
||||
MsgWebhookLLMTimeout: "LLM 处理超时",
|
||||
MsgWebhookLaneSaturated: "Webhook 处理通道已满",
|
||||
MsgWebhookLocalhostOnlyViolation: "此 Webhook 仅限本地调用",
|
||||
MsgWebhookMediaChannelUnsupported: "频道不支持媒体附件",
|
||||
MsgWebhookIPDenied: "请求来源不在 IP 白名单中",
|
||||
MsgWebhookEncryptionUnavailable: "Webhook 加密密钥未配置;请设置 GOCLAW_ENCRYPTION_KEY 以启用 Webhook",
|
||||
MsgWebhookAuthFailed: "Webhook 身份验证失败",
|
||||
MsgWebhookHMACInvalid: "HMAC 签名无效",
|
||||
MsgWebhookHMACTimestampSkew: "请求时间戳超出可接受窗口",
|
||||
MsgWebhookBearerRequiredHMAC: "此 Webhook 需要 HMAC 身份验证",
|
||||
MsgWebhookRevoked: "Webhook 已被撤销",
|
||||
MsgWebhookKindMismatch: "请求类型与 Webhook 配置不匹配",
|
||||
MsgWebhookRateLimited: "超出 Webhook 速率限制",
|
||||
MsgWebhookBodyTooLarge: "请求正文超出大小限制",
|
||||
MsgWebhookIdempotencyConflict: "幂等键冲突:请求正文不匹配",
|
||||
MsgWebhookTenantMismatch: "Webhook 租户不匹配",
|
||||
MsgWebhookAgentNotFound: "未找到 Webhook 代理",
|
||||
MsgWebhookChannelNotFound: "未找到 Webhook 频道",
|
||||
MsgWebhookMediaSSRFBlocked: "媒体 URL 被 SSRF 策略拦截",
|
||||
MsgWebhookMediaTooLarge: "媒体文件超出大小限制",
|
||||
MsgWebhookMediaMIMEDenied: "媒体 MIME 类型不被允许",
|
||||
MsgWebhookCallbackURLInvalid: "回调 URL 无效或被拦截",
|
||||
MsgWebhookLLMTimeout: "LLM 处理超时",
|
||||
MsgWebhookLaneSaturated: "Webhook 处理通道已满",
|
||||
MsgWebhookLocalhostOnlyViolation: "此 Webhook 仅限本地调用",
|
||||
MsgWebhookMediaChannelUnsupported: "频道不支持媒体附件",
|
||||
MsgWebhookIPDenied: "请求来源不在 IP 白名单中",
|
||||
MsgWebhookEncryptionUnavailable: "Webhook 加密密钥未配置;请设置 GOCLAW_ENCRYPTION_KEY 以启用 Webhook",
|
||||
MsgWebhookMessageTestRequiresStandard: "测试 message 类型 Webhook 需要 Standard 版本",
|
||||
|
||||
// Hooks
|
||||
// Workstation
|
||||
|
||||
+23
-22
@@ -300,28 +300,29 @@ const (
|
||||
MsgTenantScopeRequired = "error.tenant_scope_required" // "tenant scope is required for this operation"
|
||||
|
||||
// --- Webhooks ---
|
||||
MsgWebhookAuthFailed = "webhook.auth_failed" // "webhook authentication failed"
|
||||
MsgWebhookHMACInvalid = "webhook.hmac_invalid" // "HMAC signature is invalid"
|
||||
MsgWebhookHMACTimestampSkew = "webhook.hmac_timestamp_skew" // "request timestamp outside acceptable window"
|
||||
MsgWebhookBearerRequiredHMAC = "webhook.bearer_required_hmac" // "this webhook requires HMAC authentication"
|
||||
MsgWebhookRevoked = "webhook.revoked" // "webhook has been revoked"
|
||||
MsgWebhookKindMismatch = "webhook.kind_mismatch" // "request kind does not match webhook configuration"
|
||||
MsgWebhookRateLimited = "webhook.rate_limited" // "webhook rate limit exceeded"
|
||||
MsgWebhookBodyTooLarge = "webhook.body_too_large" // "request body exceeds size limit"
|
||||
MsgWebhookIdempotencyConflict = "webhook.idempotency_conflict" // "idempotency key conflict: request body mismatch"
|
||||
MsgWebhookTenantMismatch = "webhook.tenant_mismatch" // "webhook tenant mismatch"
|
||||
MsgWebhookAgentNotFound = "webhook.agent_not_found" // "webhook agent not found"
|
||||
MsgWebhookChannelNotFound = "webhook.channel_not_found" // "webhook channel not found"
|
||||
MsgWebhookMediaSSRFBlocked = "webhook.media_ssrf_blocked" // "media URL blocked by SSRF policy"
|
||||
MsgWebhookMediaTooLarge = "webhook.media_too_large" // "media file exceeds size limit"
|
||||
MsgWebhookMediaMIMEDenied = "webhook.media_mime_denied" // "media MIME type is not allowed"
|
||||
MsgWebhookCallbackURLInvalid = "webhook.callback_url_invalid" // "callback URL is invalid or blocked"
|
||||
MsgWebhookLLMTimeout = "webhook.llm_timeout" // "LLM processing timed out"
|
||||
MsgWebhookLaneSaturated = "webhook.lane_saturated" // "webhook processing lane is at capacity"
|
||||
MsgWebhookLocalhostOnlyViolation = "webhook.localhost_only_violation" // "this webhook is restricted to localhost callers"
|
||||
MsgWebhookMediaChannelUnsupported = "webhook.media_channel_unsupported" // "channel does not support media attachments"
|
||||
MsgWebhookIPDenied = "webhook.ip_denied" // "request origin is not in the IP allowlist"
|
||||
MsgWebhookEncryptionUnavailable = "webhook.encryption_unavailable" // "webhook encryption key not configured; set GOCLAW_ENCRYPTION_KEY to enable webhooks"
|
||||
MsgWebhookAuthFailed = "webhook.auth_failed" // "webhook authentication failed"
|
||||
MsgWebhookHMACInvalid = "webhook.hmac_invalid" // "HMAC signature is invalid"
|
||||
MsgWebhookHMACTimestampSkew = "webhook.hmac_timestamp_skew" // "request timestamp outside acceptable window"
|
||||
MsgWebhookBearerRequiredHMAC = "webhook.bearer_required_hmac" // "this webhook requires HMAC authentication"
|
||||
MsgWebhookRevoked = "webhook.revoked" // "webhook has been revoked"
|
||||
MsgWebhookKindMismatch = "webhook.kind_mismatch" // "request kind does not match webhook configuration"
|
||||
MsgWebhookRateLimited = "webhook.rate_limited" // "webhook rate limit exceeded"
|
||||
MsgWebhookBodyTooLarge = "webhook.body_too_large" // "request body exceeds size limit"
|
||||
MsgWebhookIdempotencyConflict = "webhook.idempotency_conflict" // "idempotency key conflict: request body mismatch"
|
||||
MsgWebhookTenantMismatch = "webhook.tenant_mismatch" // "webhook tenant mismatch"
|
||||
MsgWebhookAgentNotFound = "webhook.agent_not_found" // "webhook agent not found"
|
||||
MsgWebhookChannelNotFound = "webhook.channel_not_found" // "webhook channel not found"
|
||||
MsgWebhookMediaSSRFBlocked = "webhook.media_ssrf_blocked" // "media URL blocked by SSRF policy"
|
||||
MsgWebhookMediaTooLarge = "webhook.media_too_large" // "media file exceeds size limit"
|
||||
MsgWebhookMediaMIMEDenied = "webhook.media_mime_denied" // "media MIME type is not allowed"
|
||||
MsgWebhookCallbackURLInvalid = "webhook.callback_url_invalid" // "callback URL is invalid or blocked"
|
||||
MsgWebhookLLMTimeout = "webhook.llm_timeout" // "LLM processing timed out"
|
||||
MsgWebhookLaneSaturated = "webhook.lane_saturated" // "webhook processing lane is at capacity"
|
||||
MsgWebhookLocalhostOnlyViolation = "webhook.localhost_only_violation" // "this webhook is restricted to localhost callers"
|
||||
MsgWebhookMediaChannelUnsupported = "webhook.media_channel_unsupported" // "channel does not support media attachments"
|
||||
MsgWebhookIPDenied = "webhook.ip_denied" // "request origin is not in the IP allowlist"
|
||||
MsgWebhookEncryptionUnavailable = "webhook.encryption_unavailable" // "webhook encryption key not configured; set GOCLAW_ENCRYPTION_KEY to enable webhooks"
|
||||
MsgWebhookMessageTestRequiresStandard = "webhook.message_test_requires_standard" // "testing message webhooks requires Standard edition"
|
||||
|
||||
// --- Workstation permissions ---
|
||||
MsgWorkstationCmdDenied = "error.workstation_cmd_denied" // "command denied by workstation policy: %s"
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
FileArchive,
|
||||
DatabaseBackup,
|
||||
Webhook,
|
||||
Cable,
|
||||
MonitorCog,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -99,6 +100,9 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
|
||||
|
||||
<SidebarGroup label={t("groups.connectivity")} collapsed={collapsed}>
|
||||
<SidebarItem to={ROUTES.CHANNELS} icon={Radio} label={t("nav.channels")} collapsed={collapsed} />
|
||||
{isAdmin && (
|
||||
<SidebarItem to={ROUTES.WEBHOOKS} icon={Cable} label={t("nav.webhooks")} collapsed={collapsed} />
|
||||
)}
|
||||
<SidebarItem to={ROUTES.NODES} icon={Link} label={t("nav.nodes")} collapsed={collapsed} badge={pendingCount} />
|
||||
{isAdmin && (
|
||||
<SidebarItem to={ROUTES.WORKSTATIONS} icon={MonitorCog} label={t("nav.workstations")} collapsed={collapsed} />
|
||||
|
||||
@@ -41,6 +41,7 @@ import enImportExport from "./locales/en/import-export.json";
|
||||
import enV3Capabilities from "./locales/en/v3-capabilities.json";
|
||||
import enBackup from "./locales/en/backup.json";
|
||||
import enHooks from "./locales/en/hooks.json";
|
||||
import enWebhooks from "./locales/en/webhooks.json";
|
||||
import enWorkstations from "./locales/en/workstations.json";
|
||||
|
||||
// --- VI namespaces ---
|
||||
@@ -83,6 +84,7 @@ import viImportExport from "./locales/vi/import-export.json";
|
||||
import viV3Capabilities from "./locales/vi/v3-capabilities.json";
|
||||
import viBackup from "./locales/vi/backup.json";
|
||||
import viHooks from "./locales/vi/hooks.json";
|
||||
import viWebhooks from "./locales/vi/webhooks.json";
|
||||
import viWorkstations from "./locales/vi/workstations.json";
|
||||
|
||||
// --- ZH namespaces ---
|
||||
@@ -125,6 +127,7 @@ import zhImportExport from "./locales/zh/import-export.json";
|
||||
import zhV3Capabilities from "./locales/zh/v3-capabilities.json";
|
||||
import zhBackup from "./locales/zh/backup.json";
|
||||
import zhHooks from "./locales/zh/hooks.json";
|
||||
import zhWebhooks from "./locales/zh/webhooks.json";
|
||||
import zhWorkstations from "./locales/zh/workstations.json";
|
||||
|
||||
const STORAGE_KEY = "goclaw:language";
|
||||
@@ -148,6 +151,7 @@ const ns = [
|
||||
"v3-capabilities",
|
||||
"backup",
|
||||
"hooks",
|
||||
"webhooks",
|
||||
"workstations",
|
||||
] as const;
|
||||
|
||||
@@ -171,6 +175,7 @@ i18n.use(initReactI18next).init({
|
||||
"v3-capabilities": enV3Capabilities,
|
||||
backup: enBackup,
|
||||
hooks: enHooks,
|
||||
webhooks: enWebhooks,
|
||||
workstations: enWorkstations,
|
||||
},
|
||||
vi: {
|
||||
@@ -191,6 +196,7 @@ i18n.use(initReactI18next).init({
|
||||
"v3-capabilities": viV3Capabilities,
|
||||
backup: viBackup,
|
||||
hooks: viHooks,
|
||||
webhooks: viWebhooks,
|
||||
workstations: viWorkstations,
|
||||
},
|
||||
zh: {
|
||||
@@ -211,6 +217,7 @@ i18n.use(initReactI18next).init({
|
||||
"v3-capabilities": zhV3Capabilities,
|
||||
backup: zhBackup,
|
||||
hooks: zhHooks,
|
||||
webhooks: zhWebhooks,
|
||||
workstations: zhWorkstations,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"sessions": "Sessions",
|
||||
"pendingMessages": "Pending Messages",
|
||||
"channels": "Channels",
|
||||
"webhooks": "Webhooks",
|
||||
"contacts": "Contacts",
|
||||
"skills": "Skills",
|
||||
"cron": "Cron",
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"title": "Webhooks",
|
||||
"description": "Inbound HTTP webhooks that trigger an agent (LLM) or send a channel message.",
|
||||
"addWebhook": "Create Webhook",
|
||||
"searchPlaceholder": "Search webhooks...",
|
||||
"showRevoked": "Show revoked",
|
||||
"emptyTitle": "No webhooks",
|
||||
"emptyDescription": "Create your first webhook to expose an agent or channel over HTTP.",
|
||||
"neverUsed": "Never used",
|
||||
"kind": {
|
||||
"llm": "LLM",
|
||||
"message": "Message"
|
||||
},
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"revoked": "Revoked"
|
||||
},
|
||||
"columns": {
|
||||
"name": "Name",
|
||||
"webhookId": "Webhook ID",
|
||||
"kind": "Kind",
|
||||
"target": "Target",
|
||||
"status": "Status",
|
||||
"lastUsed": "Last used",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"actions": {
|
||||
"test": "Test",
|
||||
"calls": "Delivery history",
|
||||
"edit": "Edit",
|
||||
"rotate": "Rotate secret",
|
||||
"revoke": "Revoke"
|
||||
},
|
||||
"form": {
|
||||
"createTitle": "Create Webhook",
|
||||
"editTitle": "Edit Webhook",
|
||||
"description": "Configure how this webhook authenticates and what it triggers.",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "e.g. CRM lead handler",
|
||||
"kind": "Kind",
|
||||
"messageStandardOnly": "Standard edition only",
|
||||
"agent": "Agent",
|
||||
"agentPlaceholder": "Select an agent",
|
||||
"agentHint": "Required to invoke and test an LLM webhook.",
|
||||
"none": "None",
|
||||
"channel": "Channel",
|
||||
"channelPlaceholder": "Select a channel",
|
||||
"channelAny": "Any (specify per request)",
|
||||
"rateLimit": "Rate limit (requests / minute)",
|
||||
"ipAllowlist": "IP allowlist",
|
||||
"ipAllowlistPlaceholder": "e.g. 203.0.113.0/24, 198.51.100.7",
|
||||
"ipAllowlistHint": "Comma-separated IPs or CIDRs. Empty allows all.",
|
||||
"requireHmac": "Require HMAC",
|
||||
"requireHmacHint": "Reject bearer-token auth; require signed requests only.",
|
||||
"localhostOnly": "Localhost only",
|
||||
"localhostOnlyHint": "Only accept requests from 127.0.0.1 / ::1.",
|
||||
"localhostOnlyLite": "Forced on in Lite edition.",
|
||||
"cancel": "Cancel",
|
||||
"create": "Create",
|
||||
"save": "Save",
|
||||
"saving": "Saving..."
|
||||
},
|
||||
"secret": {
|
||||
"title": "Webhook secret",
|
||||
"description": "Copy these values now — they are shown only once.",
|
||||
"warning": "This secret will not be shown again. Store it securely.",
|
||||
"webhookId": "Webhook ID (X-Webhook-Id header)",
|
||||
"bearer": "Secret (Bearer token)",
|
||||
"hmacKey": "HMAC signing key",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"usageHint": "Send the secret as 'Authorization: Bearer <secret>', or sign requests with the HMAC key.",
|
||||
"done": "Done"
|
||||
},
|
||||
"test": {
|
||||
"title": "Test webhook: {{name}}",
|
||||
"description": "Invoke this webhook server-side using your admin session (no secret needed).",
|
||||
"messageWarning": "This sends a real message to the target chat.",
|
||||
"channelName": "Channel name",
|
||||
"channelNamePlaceholder": "telegram-main",
|
||||
"input": "Input",
|
||||
"inputPlaceholder": "Enter a prompt to send to the agent",
|
||||
"chatId": "Chat ID",
|
||||
"chatIdPlaceholder": "Recipient chat identifier",
|
||||
"content": "Content",
|
||||
"result": "Result",
|
||||
"sent": "Sent to {{channel}} (chat {{chatId}})",
|
||||
"run": "Send test",
|
||||
"running": "Sending...",
|
||||
"close": "Close",
|
||||
"failed": "Test failed"
|
||||
},
|
||||
"calls": {
|
||||
"title": "Delivery history: {{name}}",
|
||||
"description": "Recent invocations of this webhook.",
|
||||
"statusAll": "All statuses",
|
||||
"status": {
|
||||
"queued": "Queued",
|
||||
"running": "Running",
|
||||
"done": "Done",
|
||||
"failed": "Failed",
|
||||
"dead": "Dead"
|
||||
},
|
||||
"cols": {
|
||||
"status": "Status",
|
||||
"mode": "Mode",
|
||||
"attempts": "Attempts",
|
||||
"created": "Created",
|
||||
"detail": "Detail"
|
||||
},
|
||||
"emptyTitle": "No deliveries yet",
|
||||
"emptyDescription": "Calls to this webhook will appear here.",
|
||||
"noMore": "No more calls.",
|
||||
"page": "Page {{page}}",
|
||||
"prev": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"callDetail": {
|
||||
"title": "Delivery detail",
|
||||
"description": "Full record of a single webhook invocation.",
|
||||
"loading": "Loading...",
|
||||
"deliveryId": "Delivery ID",
|
||||
"idempotencyKey": "Idempotency key",
|
||||
"callbackUrl": "Callback URL",
|
||||
"created": "Created",
|
||||
"started": "Started",
|
||||
"completed": "Completed",
|
||||
"nextAttempt": "Next attempt",
|
||||
"lastError": "Last error",
|
||||
"requestPayload": "Request payload",
|
||||
"response": "Response"
|
||||
},
|
||||
"rotate": {
|
||||
"title": "Rotate secret",
|
||||
"description": "Rotating invalidates the current secret for \"{{name}}\". A new secret will be shown once."
|
||||
},
|
||||
"revoke": {
|
||||
"title": "Revoke webhook",
|
||||
"description": "Revoking permanently disables \"{{name}}\". This cannot be undone."
|
||||
},
|
||||
"toast": {
|
||||
"created": "Webhook created",
|
||||
"updated": "Webhook updated",
|
||||
"secretRotated": "Secret rotated",
|
||||
"revoked": "Webhook revoked",
|
||||
"failedCreate": "Failed to create webhook",
|
||||
"failedUpdate": "Failed to update webhook",
|
||||
"failedRotate": "Failed to rotate secret",
|
||||
"failedRevoke": "Failed to revoke webhook"
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
"sessions": "Session",
|
||||
"pendingMessages": "Tin nhắn chờ",
|
||||
"channels": "Channel",
|
||||
"webhooks": "Webhook",
|
||||
"contacts": "Liên hệ",
|
||||
"skills": "Skill",
|
||||
"cron": "Tác vụ định kỳ",
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"title": "Webhook",
|
||||
"description": "Webhook HTTP đến để gọi agent (LLM) hoặc gửi tin nhắn qua channel.",
|
||||
"addWebhook": "Tạo Webhook",
|
||||
"searchPlaceholder": "Tìm webhook...",
|
||||
"showRevoked": "Hiện đã thu hồi",
|
||||
"emptyTitle": "Chưa có webhook",
|
||||
"emptyDescription": "Tạo webhook đầu tiên để mở agent hoặc channel qua HTTP.",
|
||||
"neverUsed": "Chưa dùng",
|
||||
"kind": {
|
||||
"llm": "LLM",
|
||||
"message": "Tin nhắn"
|
||||
},
|
||||
"status": {
|
||||
"active": "Hoạt động",
|
||||
"revoked": "Đã thu hồi"
|
||||
},
|
||||
"columns": {
|
||||
"name": "Tên",
|
||||
"webhookId": "Webhook ID",
|
||||
"kind": "Loại",
|
||||
"target": "Đích",
|
||||
"status": "Trạng thái",
|
||||
"lastUsed": "Dùng gần nhất",
|
||||
"actions": "Thao tác"
|
||||
},
|
||||
"actions": {
|
||||
"test": "Kiểm thử",
|
||||
"calls": "Lịch sử gọi",
|
||||
"edit": "Sửa",
|
||||
"rotate": "Xoay secret",
|
||||
"revoke": "Thu hồi"
|
||||
},
|
||||
"form": {
|
||||
"createTitle": "Tạo Webhook",
|
||||
"editTitle": "Sửa Webhook",
|
||||
"description": "Cấu hình cách webhook xác thực và việc nó kích hoạt.",
|
||||
"name": "Tên",
|
||||
"namePlaceholder": "vd: Xử lý lead CRM",
|
||||
"kind": "Loại",
|
||||
"messageStandardOnly": "Chỉ bản Standard",
|
||||
"agent": "Agent",
|
||||
"agentPlaceholder": "Chọn agent",
|
||||
"agentHint": "Bắt buộc để gọi và kiểm thử webhook LLM.",
|
||||
"none": "Không",
|
||||
"channel": "Channel",
|
||||
"channelPlaceholder": "Chọn channel",
|
||||
"channelAny": "Bất kỳ (chỉ định khi gọi)",
|
||||
"rateLimit": "Giới hạn (request / phút)",
|
||||
"ipAllowlist": "Danh sách IP cho phép",
|
||||
"ipAllowlistPlaceholder": "vd: 203.0.113.0/24, 198.51.100.7",
|
||||
"ipAllowlistHint": "IP hoặc CIDR, cách nhau bằng dấu phẩy. Để trống cho phép tất cả.",
|
||||
"requireHmac": "Bắt buộc HMAC",
|
||||
"requireHmacHint": "Từ chối xác thực bearer token; chỉ chấp nhận request đã ký.",
|
||||
"localhostOnly": "Chỉ localhost",
|
||||
"localhostOnlyHint": "Chỉ chấp nhận request từ 127.0.0.1 / ::1.",
|
||||
"localhostOnlyLite": "Bị bật cố định ở bản Lite.",
|
||||
"cancel": "Hủy",
|
||||
"create": "Tạo",
|
||||
"save": "Lưu",
|
||||
"saving": "Đang lưu..."
|
||||
},
|
||||
"secret": {
|
||||
"title": "Secret của webhook",
|
||||
"description": "Sao chép các giá trị này ngay — chỉ hiển thị một lần.",
|
||||
"warning": "Secret này sẽ không hiển thị lại. Hãy lưu trữ an toàn.",
|
||||
"webhookId": "Webhook ID (header X-Webhook-Id)",
|
||||
"bearer": "Secret (Bearer token)",
|
||||
"hmacKey": "Khóa ký HMAC",
|
||||
"copy": "Sao chép",
|
||||
"copied": "Đã chép",
|
||||
"usageHint": "Gửi secret qua 'Authorization: Bearer <secret>', hoặc ký request bằng khóa HMAC.",
|
||||
"done": "Xong"
|
||||
},
|
||||
"test": {
|
||||
"title": "Kiểm thử webhook: {{name}}",
|
||||
"description": "Gọi webhook phía máy chủ bằng phiên admin của bạn (không cần secret).",
|
||||
"messageWarning": "Thao tác này gửi tin nhắn thật đến chat đích.",
|
||||
"channelName": "Tên channel",
|
||||
"channelNamePlaceholder": "telegram-main",
|
||||
"input": "Đầu vào",
|
||||
"inputPlaceholder": "Nhập prompt gửi cho agent",
|
||||
"chatId": "Chat ID",
|
||||
"chatIdPlaceholder": "Định danh chat người nhận",
|
||||
"content": "Nội dung",
|
||||
"result": "Kết quả",
|
||||
"sent": "Đã gửi tới {{channel}} (chat {{chatId}})",
|
||||
"run": "Gửi thử",
|
||||
"running": "Đang gửi...",
|
||||
"close": "Đóng",
|
||||
"failed": "Kiểm thử thất bại"
|
||||
},
|
||||
"calls": {
|
||||
"title": "Lịch sử gọi: {{name}}",
|
||||
"description": "Các lần gọi gần đây của webhook này.",
|
||||
"statusAll": "Tất cả trạng thái",
|
||||
"status": {
|
||||
"queued": "Chờ",
|
||||
"running": "Đang chạy",
|
||||
"done": "Xong",
|
||||
"failed": "Lỗi",
|
||||
"dead": "Bỏ"
|
||||
},
|
||||
"cols": {
|
||||
"status": "Trạng thái",
|
||||
"mode": "Chế độ",
|
||||
"attempts": "Số lần",
|
||||
"created": "Tạo lúc",
|
||||
"detail": "Chi tiết"
|
||||
},
|
||||
"emptyTitle": "Chưa có lần gọi nào",
|
||||
"emptyDescription": "Các lần gọi webhook này sẽ hiển thị ở đây.",
|
||||
"noMore": "Không còn lần gọi nào.",
|
||||
"page": "Trang {{page}}",
|
||||
"prev": "Trước",
|
||||
"next": "Sau"
|
||||
},
|
||||
"callDetail": {
|
||||
"title": "Chi tiết lần gọi",
|
||||
"description": "Bản ghi đầy đủ của một lần gọi webhook.",
|
||||
"loading": "Đang tải...",
|
||||
"deliveryId": "Delivery ID",
|
||||
"idempotencyKey": "Idempotency key",
|
||||
"callbackUrl": "Callback URL",
|
||||
"created": "Tạo lúc",
|
||||
"started": "Bắt đầu",
|
||||
"completed": "Hoàn tất",
|
||||
"nextAttempt": "Lần thử kế",
|
||||
"lastError": "Lỗi gần nhất",
|
||||
"requestPayload": "Payload request",
|
||||
"response": "Phản hồi"
|
||||
},
|
||||
"rotate": {
|
||||
"title": "Xoay secret",
|
||||
"description": "Xoay sẽ vô hiệu secret hiện tại của \"{{name}}\". Secret mới sẽ hiển thị một lần."
|
||||
},
|
||||
"revoke": {
|
||||
"title": "Thu hồi webhook",
|
||||
"description": "Thu hồi sẽ vô hiệu vĩnh viễn \"{{name}}\". Không thể hoàn tác."
|
||||
},
|
||||
"toast": {
|
||||
"created": "Đã tạo webhook",
|
||||
"updated": "Đã cập nhật webhook",
|
||||
"secretRotated": "Đã xoay secret",
|
||||
"revoked": "Đã thu hồi webhook",
|
||||
"failedCreate": "Tạo webhook thất bại",
|
||||
"failedUpdate": "Cập nhật webhook thất bại",
|
||||
"failedRotate": "Xoay secret thất bại",
|
||||
"failedRevoke": "Thu hồi webhook thất bại"
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
"importExport": "Import & Export",
|
||||
"builtinTools": "内置工具",
|
||||
"channels": "Channel",
|
||||
"webhooks": "Webhook",
|
||||
"contacts": "联系人",
|
||||
"chat": "对话",
|
||||
"config": "配置",
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"title": "Webhook",
|
||||
"description": "用于触发智能体(LLM)或发送频道消息的入站 HTTP Webhook。",
|
||||
"addWebhook": "创建 Webhook",
|
||||
"searchPlaceholder": "搜索 Webhook...",
|
||||
"showRevoked": "显示已吊销",
|
||||
"emptyTitle": "暂无 Webhook",
|
||||
"emptyDescription": "创建第一个 Webhook,通过 HTTP 暴露智能体或频道。",
|
||||
"neverUsed": "从未使用",
|
||||
"kind": {
|
||||
"llm": "LLM",
|
||||
"message": "消息"
|
||||
},
|
||||
"status": {
|
||||
"active": "启用",
|
||||
"revoked": "已吊销"
|
||||
},
|
||||
"columns": {
|
||||
"name": "名称",
|
||||
"webhookId": "Webhook ID",
|
||||
"kind": "类型",
|
||||
"target": "目标",
|
||||
"status": "状态",
|
||||
"lastUsed": "最近使用",
|
||||
"actions": "操作"
|
||||
},
|
||||
"actions": {
|
||||
"test": "测试",
|
||||
"calls": "调用历史",
|
||||
"edit": "编辑",
|
||||
"rotate": "轮换密钥",
|
||||
"revoke": "吊销"
|
||||
},
|
||||
"form": {
|
||||
"createTitle": "创建 Webhook",
|
||||
"editTitle": "编辑 Webhook",
|
||||
"description": "配置此 Webhook 的鉴权方式及触发内容。",
|
||||
"name": "名称",
|
||||
"namePlaceholder": "例如:CRM 线索处理",
|
||||
"kind": "类型",
|
||||
"messageStandardOnly": "仅 Standard 版本",
|
||||
"agent": "智能体",
|
||||
"agentPlaceholder": "选择智能体",
|
||||
"agentHint": "调用和测试 LLM Webhook 时必填。",
|
||||
"none": "无",
|
||||
"channel": "频道",
|
||||
"channelPlaceholder": "选择频道",
|
||||
"channelAny": "任意(按请求指定)",
|
||||
"rateLimit": "速率限制(请求 / 分钟)",
|
||||
"ipAllowlist": "IP 白名单",
|
||||
"ipAllowlistPlaceholder": "例如:203.0.113.0/24, 198.51.100.7",
|
||||
"ipAllowlistHint": "以逗号分隔的 IP 或 CIDR。留空则允许全部。",
|
||||
"requireHmac": "强制 HMAC",
|
||||
"requireHmacHint": "拒绝 Bearer 令牌鉴权,仅接受签名请求。",
|
||||
"localhostOnly": "仅本地",
|
||||
"localhostOnlyHint": "仅接受来自 127.0.0.1 / ::1 的请求。",
|
||||
"localhostOnlyLite": "Lite 版本中强制开启。",
|
||||
"cancel": "取消",
|
||||
"create": "创建",
|
||||
"save": "保存",
|
||||
"saving": "保存中..."
|
||||
},
|
||||
"secret": {
|
||||
"title": "Webhook 密钥",
|
||||
"description": "请立即复制这些值——它们仅显示一次。",
|
||||
"warning": "此密钥不会再次显示,请妥善保存。",
|
||||
"webhookId": "Webhook ID(X-Webhook-Id 请求头)",
|
||||
"bearer": "密钥(Bearer 令牌)",
|
||||
"hmacKey": "HMAC 签名密钥",
|
||||
"copy": "复制",
|
||||
"copied": "已复制",
|
||||
"usageHint": "通过 'Authorization: Bearer <secret>' 发送密钥,或使用 HMAC 密钥签名请求。",
|
||||
"done": "完成"
|
||||
},
|
||||
"test": {
|
||||
"title": "测试 Webhook:{{name}}",
|
||||
"description": "使用您的管理员会话在服务端调用此 Webhook(无需密钥)。",
|
||||
"messageWarning": "此操作会向目标聊天发送真实消息。",
|
||||
"channelName": "频道名称",
|
||||
"channelNamePlaceholder": "telegram-main",
|
||||
"input": "输入",
|
||||
"inputPlaceholder": "输入发送给智能体的提示词",
|
||||
"chatId": "聊天 ID",
|
||||
"chatIdPlaceholder": "接收方聊天标识",
|
||||
"content": "内容",
|
||||
"result": "结果",
|
||||
"sent": "已发送至 {{channel}}(聊天 {{chatId}})",
|
||||
"run": "发送测试",
|
||||
"running": "发送中...",
|
||||
"close": "关闭",
|
||||
"failed": "测试失败"
|
||||
},
|
||||
"calls": {
|
||||
"title": "调用历史:{{name}}",
|
||||
"description": "此 Webhook 的最近调用。",
|
||||
"statusAll": "全部状态",
|
||||
"status": {
|
||||
"queued": "排队中",
|
||||
"running": "运行中",
|
||||
"done": "完成",
|
||||
"failed": "失败",
|
||||
"dead": "已废弃"
|
||||
},
|
||||
"cols": {
|
||||
"status": "状态",
|
||||
"mode": "模式",
|
||||
"attempts": "尝试次数",
|
||||
"created": "创建时间",
|
||||
"detail": "详情"
|
||||
},
|
||||
"emptyTitle": "尚无调用",
|
||||
"emptyDescription": "对此 Webhook 的调用将显示在此处。",
|
||||
"noMore": "没有更多调用。",
|
||||
"page": "第 {{page}} 页",
|
||||
"prev": "上一页",
|
||||
"next": "下一页"
|
||||
},
|
||||
"callDetail": {
|
||||
"title": "调用详情",
|
||||
"description": "单次 Webhook 调用的完整记录。",
|
||||
"loading": "加载中...",
|
||||
"deliveryId": "Delivery ID",
|
||||
"idempotencyKey": "幂等键",
|
||||
"callbackUrl": "回调 URL",
|
||||
"created": "创建时间",
|
||||
"started": "开始时间",
|
||||
"completed": "完成时间",
|
||||
"nextAttempt": "下次重试",
|
||||
"lastError": "最近错误",
|
||||
"requestPayload": "请求载荷",
|
||||
"response": "响应"
|
||||
},
|
||||
"rotate": {
|
||||
"title": "轮换密钥",
|
||||
"description": "轮换将使 \"{{name}}\" 的当前密钥失效。新密钥仅显示一次。"
|
||||
},
|
||||
"revoke": {
|
||||
"title": "吊销 Webhook",
|
||||
"description": "吊销将永久禁用 \"{{name}}\",且无法撤销。"
|
||||
},
|
||||
"toast": {
|
||||
"created": "已创建 Webhook",
|
||||
"updated": "已更新 Webhook",
|
||||
"secretRotated": "已轮换密钥",
|
||||
"revoked": "已吊销 Webhook",
|
||||
"failedCreate": "创建 Webhook 失败",
|
||||
"failedUpdate": "更新 Webhook 失败",
|
||||
"failedRotate": "轮换密钥失败",
|
||||
"failedRevoke": "吊销 Webhook 失败"
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,11 @@ export const queryKeys = {
|
||||
detail: (id: string) => ["hooks", id] as const,
|
||||
history: (id: string) => ["hooks", id, "history"] as const,
|
||||
},
|
||||
webhooks: {
|
||||
all: ["webhooks"] as const,
|
||||
calls: (id: string, params: Record<string, unknown>) => ["webhooks", id, "calls", params] as const,
|
||||
call: (id: string, callId: string) => ["webhooks", id, "calls", callId] as const,
|
||||
},
|
||||
builtinTools: {
|
||||
all: ["builtinTools"] as const,
|
||||
},
|
||||
|
||||
@@ -47,6 +47,7 @@ export const ROUTES = {
|
||||
SELECT_TENANT: "/select-tenant",
|
||||
HOOKS: "/hooks",
|
||||
HOOK_DETAIL: "/hooks/:id",
|
||||
WEBHOOKS: "/webhooks",
|
||||
WORKSTATIONS: "/workstations",
|
||||
WORKSTATION_DETAIL: "/workstations/:id",
|
||||
} as const;
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useCallback } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import i18next from "i18next";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import type {
|
||||
WebhookData,
|
||||
WebhookCreateInput,
|
||||
WebhookCreateResponse,
|
||||
WebhookUpdateInput,
|
||||
WebhookRotateResponse,
|
||||
WebhookCallData,
|
||||
WebhookCallDetail,
|
||||
WebhookTestInput,
|
||||
WebhookTestResult,
|
||||
} from "@/types/webhook";
|
||||
|
||||
export function useWebhooks() {
|
||||
const http = useHttp();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: webhooks = [], isLoading: loading } = useQuery({
|
||||
queryKey: queryKeys.webhooks.all,
|
||||
queryFn: () => http.get<WebhookData[]>("/v1/webhooks"),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const invalidate = useCallback(
|
||||
() => queryClient.invalidateQueries({ queryKey: queryKeys.webhooks.all }),
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const createWebhook = useCallback(
|
||||
async (data: WebhookCreateInput): Promise<WebhookCreateResponse> => {
|
||||
try {
|
||||
const res = await http.post<WebhookCreateResponse>("/v1/webhooks", data);
|
||||
await invalidate();
|
||||
toast.success(i18next.t("webhooks:toast.created"));
|
||||
return res;
|
||||
} catch (err) {
|
||||
toast.error(i18next.t("webhooks:toast.failedCreate"), err instanceof Error ? err.message : "");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const updateWebhook = useCallback(
|
||||
async (id: string, data: WebhookUpdateInput): Promise<void> => {
|
||||
try {
|
||||
await http.patch(`/v1/webhooks/${id}`, data);
|
||||
await invalidate();
|
||||
toast.success(i18next.t("webhooks:toast.updated"));
|
||||
} catch (err) {
|
||||
toast.error(i18next.t("webhooks:toast.failedUpdate"), err instanceof Error ? err.message : "");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const rotateSecret = useCallback(
|
||||
async (id: string): Promise<WebhookRotateResponse> => {
|
||||
try {
|
||||
const res = await http.post<WebhookRotateResponse>(`/v1/webhooks/${id}/rotate`, {});
|
||||
await invalidate();
|
||||
toast.success(i18next.t("webhooks:toast.secretRotated"));
|
||||
return res;
|
||||
} catch (err) {
|
||||
toast.error(i18next.t("webhooks:toast.failedRotate"), err instanceof Error ? err.message : "");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const revokeWebhook = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
try {
|
||||
await http.delete(`/v1/webhooks/${id}`);
|
||||
await invalidate();
|
||||
toast.success(i18next.t("webhooks:toast.revoked"));
|
||||
} catch (err) {
|
||||
toast.error(i18next.t("webhooks:toast.failedRevoke"), err instanceof Error ? err.message : "");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const testWebhook = useCallback(
|
||||
(id: string, body: WebhookTestInput): Promise<WebhookTestResult> =>
|
||||
http.post<WebhookTestResult>(`/v1/webhooks/${id}/test`, body),
|
||||
[http],
|
||||
);
|
||||
|
||||
return { webhooks, loading, refresh: invalidate, createWebhook, updateWebhook, rotateSecret, revokeWebhook, testWebhook };
|
||||
}
|
||||
|
||||
export function useWebhookCalls(
|
||||
id: string | null,
|
||||
status: string,
|
||||
enabled: boolean,
|
||||
limit: number,
|
||||
offset: number,
|
||||
) {
|
||||
const http = useHttp();
|
||||
const params = { status, limit, offset };
|
||||
|
||||
const { data = [], isLoading: loading, isFetching, refetch } = useQuery({
|
||||
queryKey: queryKeys.webhooks.calls(id ?? "", params),
|
||||
queryFn: () => {
|
||||
const q: Record<string, string> = { limit: String(limit), offset: String(offset) };
|
||||
if (status) q.status = status;
|
||||
return http.get<WebhookCallData[]>(`/v1/webhooks/${id}/calls`, q);
|
||||
},
|
||||
enabled: enabled && !!id,
|
||||
placeholderData: (prev) => prev,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
return { calls: data, loading, isFetching, refetch };
|
||||
}
|
||||
|
||||
export function useWebhookCallDetail(webhookId: string | null, callId: string | null) {
|
||||
const http = useHttp();
|
||||
|
||||
const { data, isLoading: loading } = useQuery({
|
||||
queryKey: queryKeys.webhooks.call(webhookId ?? "", callId ?? ""),
|
||||
queryFn: () => http.get<WebhookCallDetail>(`/v1/webhooks/${webhookId}/calls/${callId}`),
|
||||
enabled: !!webhookId && !!callId,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
return { detail: data ?? null, loading };
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useWebhookCallDetail } from "./hooks/use-webhooks";
|
||||
import type { WebhookCallDetail } from "@/types/webhook";
|
||||
|
||||
const STATUS_VARIANT: Record<WebhookCallDetail["status"], "default" | "secondary" | "destructive" | "outline"> = {
|
||||
queued: "secondary",
|
||||
running: "outline",
|
||||
done: "default",
|
||||
failed: "destructive",
|
||||
dead: "destructive",
|
||||
};
|
||||
|
||||
// Pretty-print a JSON string; fall back to the raw text if it isn't JSON.
|
||||
function prettyJSON(s?: string): string {
|
||||
if (!s) return "";
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(s), null, 2);
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(iso?: string): string {
|
||||
return iso ? new Date(iso).toLocaleString() : "—";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
webhookId: string | null;
|
||||
callId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function WebhookCallDetailDialog({ webhookId, callId, onClose }: Props) {
|
||||
const { t } = useTranslation("webhooks");
|
||||
const { detail, loading } = useWebhookCallDetail(webhookId, callId);
|
||||
|
||||
const Field = ({ label, value, mono }: { label: string; value?: string; mono?: boolean }) => (
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className={`text-sm break-all ${mono ? "font-mono" : ""}`}>{value || "—"}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={!!callId} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-sm:inset-0 max-sm:translate-x-0 max-sm:translate-y-0 sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("callDetail.title")}</DialogTitle>
|
||||
<DialogDescription>{t("callDetail.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading || !detail ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("callDetail.loading")}</p>
|
||||
) : (
|
||||
<div className="max-h-[70vh] overflow-y-auto space-y-4">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-xs text-muted-foreground">{t("calls.cols.status")}</div>
|
||||
<Badge variant={STATUS_VARIANT[detail.status]} className="text-xs">
|
||||
{t(`calls.status.${detail.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
<Field label={t("calls.cols.mode")} value={detail.mode} />
|
||||
<Field label={t("calls.cols.attempts")} value={String(detail.attempts)} />
|
||||
<Field label={t("callDetail.deliveryId")} value={detail.delivery_id} mono />
|
||||
<Field label={t("callDetail.idempotencyKey")} value={detail.idempotency_key} mono />
|
||||
<Field label={t("callDetail.callbackUrl")} value={detail.callback_url} mono />
|
||||
<Field label={t("callDetail.created")} value={fmt(detail.created_at)} />
|
||||
<Field label={t("callDetail.started")} value={fmt(detail.started_at)} />
|
||||
<Field label={t("callDetail.completed")} value={fmt(detail.completed_at)} />
|
||||
<Field label={t("callDetail.nextAttempt")} value={fmt(detail.next_attempt_at)} />
|
||||
</div>
|
||||
|
||||
{detail.last_error && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">{t("callDetail.lastError")}</div>
|
||||
<div className="rounded bg-destructive/10 border border-destructive/40 px-3 py-2 text-sm text-destructive break-words">
|
||||
{detail.last_error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.request_payload && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">{t("callDetail.requestPayload")}</div>
|
||||
<pre className="max-h-48 overflow-auto rounded bg-muted px-3 py-2 text-xs whitespace-pre-wrap break-words">
|
||||
{prettyJSON(detail.request_payload)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.response && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">{t("callDetail.response")}</div>
|
||||
<pre className="max-h-48 overflow-auto rounded bg-muted px-3 py-2 text-xs whitespace-pre-wrap break-words">
|
||||
{prettyJSON(detail.response)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { EmptyState } from "@/components/shared/empty-state";
|
||||
import { Inbox } from "lucide-react";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
import { useWebhookCalls } from "./hooks/use-webhooks";
|
||||
import { WebhookCallDetailDialog } from "./webhook-call-detail-dialog";
|
||||
import type { WebhookData, WebhookCallData } from "@/types/webhook";
|
||||
|
||||
const STATUS_VARIANT: Record<WebhookCallData["status"], "default" | "secondary" | "destructive" | "outline"> = {
|
||||
queued: "secondary",
|
||||
running: "outline",
|
||||
done: "default",
|
||||
failed: "destructive",
|
||||
dead: "destructive",
|
||||
};
|
||||
|
||||
const ALL = "__all__";
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
interface Props {
|
||||
webhook: WebhookData | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function WebhookCallsDialog({ webhook, onClose }: Props) {
|
||||
const { t } = useTranslation("webhooks");
|
||||
const [status, setStatus] = useState<string>(ALL);
|
||||
const [page, setPage] = useState(0);
|
||||
const [detailCallId, setDetailCallId] = useState<string | null>(null);
|
||||
const effectiveStatus = status === ALL ? "" : status;
|
||||
const { calls, isFetching, refetch } = useWebhookCalls(
|
||||
webhook?.id ?? null,
|
||||
effectiveStatus,
|
||||
!!webhook,
|
||||
PAGE_SIZE,
|
||||
page * PAGE_SIZE,
|
||||
);
|
||||
|
||||
// Reset to first page when the target webhook or status filter changes.
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [webhook?.id, status]);
|
||||
|
||||
// No total count from the API → infer "has more" from a full page.
|
||||
const hasNext = calls.length === PAGE_SIZE;
|
||||
const hasPrev = page > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={!!webhook} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-sm:inset-0 max-sm:translate-x-0 max-sm:translate-y-0 sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("calls.title", { name: webhook?.name ?? "" })}</DialogTitle>
|
||||
<DialogDescription>{t("calls.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="w-40 text-base md:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>{t("calls.statusAll")}</SelectItem>
|
||||
<SelectItem value="queued">{t("calls.status.queued")}</SelectItem>
|
||||
<SelectItem value="running">{t("calls.status.running")}</SelectItem>
|
||||
<SelectItem value="done">{t("calls.status.done")}</SelectItem>
|
||||
<SelectItem value="failed">{t("calls.status.failed")}</SelectItem>
|
||||
<SelectItem value="dead">{t("calls.status.dead")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching} className="gap-1">
|
||||
<RefreshCw className={isFetching ? "animate-spin h-3.5 w-3.5" : "h-3.5 w-3.5"} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 max-h-[60vh] overflow-y-auto">
|
||||
{calls.length === 0 ? (
|
||||
page === 0 ? (
|
||||
<EmptyState icon={Inbox} title={t("calls.emptyTitle")} description={t("calls.emptyDescription")} />
|
||||
) : (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("calls.noMore")}</p>
|
||||
)
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full min-w-[600px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-3 py-2 text-left font-medium">{t("calls.cols.status")}</th>
|
||||
<th className="px-3 py-2 text-left font-medium">{t("calls.cols.mode")}</th>
|
||||
<th className="px-3 py-2 text-left font-medium">{t("calls.cols.attempts")}</th>
|
||||
<th className="px-3 py-2 text-left font-medium">{t("calls.cols.created")}</th>
|
||||
<th className="px-3 py-2 text-left font-medium">{t("calls.cols.detail")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{calls.map((c) => (
|
||||
<tr
|
||||
key={c.id}
|
||||
onClick={() => setDetailCallId(c.id)}
|
||||
className="border-b last:border-0 align-top hover:bg-muted/30 cursor-pointer"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant={STATUS_VARIANT[c.status]} className="text-xs">
|
||||
{t(`calls.status.${c.status}`)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{c.mode}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{c.attempts}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground" title={new Date(c.created_at).toLocaleString()}>
|
||||
{formatRelativeTime(c.created_at)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground max-w-[260px]">
|
||||
{c.last_error ? (
|
||||
<span className="text-destructive break-words">{c.last_error}</span>
|
||||
) : c.response ? (
|
||||
<span className="break-words line-clamp-3">{c.response}</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(hasPrev || hasNext) && (
|
||||
<div className="flex items-center justify-between border-t pt-3">
|
||||
<span className="text-xs text-muted-foreground">{t("calls.page", { page: page + 1 })}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={!hasPrev || isFetching}
|
||||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" /> {t("calls.prev")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={!hasNext || isFetching}
|
||||
className="gap-1"
|
||||
>
|
||||
{t("calls.next")} <ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<WebhookCallDetailDialog
|
||||
webhookId={webhook?.id ?? null}
|
||||
callId={detailCallId}
|
||||
onClose={() => setDetailCallId(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Webhook as WebhookIcon } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuthStore } from "@/stores/use-auth-store";
|
||||
import { useAgents } from "@/pages/agents/hooks/use-agents";
|
||||
import { useChannelInstances } from "@/pages/channels/hooks/use-channel-instances";
|
||||
import { NONE, WebhookFormFields } from "./webhook-form-fields";
|
||||
import type { WebhookData, WebhookKind, WebhookCreateInput, WebhookUpdateInput } from "@/types/webhook";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editing: WebhookData | null;
|
||||
onCreate: (input: WebhookCreateInput) => Promise<void>;
|
||||
onUpdate: (id: string, input: WebhookUpdateInput) => Promise<void>;
|
||||
}
|
||||
|
||||
export function WebhookFormDialog({ open, onOpenChange, editing, onCreate, onUpdate }: Props) {
|
||||
const { t } = useTranslation("webhooks");
|
||||
const edition = useAuthStore((s) => s.edition);
|
||||
const isStandard = edition === "standard";
|
||||
const { agents } = useAgents();
|
||||
const { instances } = useChannelInstances({ limit: 200 });
|
||||
|
||||
const isEdit = !!editing;
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [kind, setKind] = useState<WebhookKind>("llm");
|
||||
const [agentId, setAgentId] = useState<string>(NONE);
|
||||
const [channelId, setChannelId] = useState<string>(NONE);
|
||||
const [rateLimit, setRateLimit] = useState<string>("60");
|
||||
const [ipAllowlist, setIpAllowlist] = useState<string>("");
|
||||
const [requireHmac, setRequireHmac] = useState(false);
|
||||
const [localhostOnly, setLocalhostOnly] = useState(!isStandard);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Hydrate from the editing target (or reset to defaults on open).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (editing) {
|
||||
setName(editing.name);
|
||||
setKind(editing.kind);
|
||||
setAgentId(editing.agent_id ?? NONE);
|
||||
setChannelId(editing.channel_id ?? NONE);
|
||||
setRateLimit(String(editing.rate_limit_per_min ?? 0));
|
||||
setIpAllowlist((editing.ip_allowlist ?? []).join(", "));
|
||||
setRequireHmac(editing.require_hmac);
|
||||
setLocalhostOnly(editing.localhost_only);
|
||||
} else {
|
||||
setName("");
|
||||
setKind("llm");
|
||||
setAgentId(NONE);
|
||||
setChannelId(NONE);
|
||||
setRateLimit("60");
|
||||
setIpAllowlist("");
|
||||
setRequireHmac(false);
|
||||
setLocalhostOnly(!isStandard);
|
||||
}
|
||||
}, [open, editing, isStandard]);
|
||||
|
||||
const parseIpList = (s: string): string[] =>
|
||||
s.split(",").map((x) => x.trim()).filter(Boolean);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const rate = parseInt(rateLimit, 10);
|
||||
if (isEdit && editing) {
|
||||
const update: WebhookUpdateInput = {
|
||||
name: name.trim(),
|
||||
channel_id: channelId === NONE ? undefined : channelId,
|
||||
rate_limit_per_min: Number.isFinite(rate) ? rate : 0,
|
||||
ip_allowlist: parseIpList(ipAllowlist),
|
||||
require_hmac: requireHmac,
|
||||
localhost_only: localhostOnly,
|
||||
};
|
||||
await onUpdate(editing.id, update);
|
||||
} else {
|
||||
const create: WebhookCreateInput = {
|
||||
name: name.trim(),
|
||||
kind,
|
||||
agent_id: kind === "llm" && agentId !== NONE ? agentId : undefined,
|
||||
channel_id: kind === "message" && channelId !== NONE ? channelId : undefined,
|
||||
rate_limit_per_min: Number.isFinite(rate) ? rate : undefined,
|
||||
ip_allowlist: parseIpList(ipAllowlist),
|
||||
require_hmac: requireHmac,
|
||||
localhost_only: localhostOnly,
|
||||
};
|
||||
await onCreate(create);
|
||||
}
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-sm:inset-0 max-sm:translate-x-0 max-sm:translate-y-0 sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<WebhookIcon className="h-5 w-5" />
|
||||
{isEdit ? t("form.editTitle") : t("form.createTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("form.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<WebhookFormFields
|
||||
isEdit={isEdit}
|
||||
isStandard={isStandard}
|
||||
name={name}
|
||||
setName={setName}
|
||||
kind={kind}
|
||||
setKind={setKind}
|
||||
agentId={agentId}
|
||||
setAgentId={setAgentId}
|
||||
channelId={channelId}
|
||||
setChannelId={setChannelId}
|
||||
rateLimit={rateLimit}
|
||||
setRateLimit={setRateLimit}
|
||||
ipAllowlist={ipAllowlist}
|
||||
setIpAllowlist={setIpAllowlist}
|
||||
requireHmac={requireHmac}
|
||||
setRequireHmac={setRequireHmac}
|
||||
localhostOnly={localhostOnly}
|
||||
setLocalhostOnly={setLocalhostOnly}
|
||||
agents={agents}
|
||||
instances={instances}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSubmit} disabled={saving || !name.trim()}>
|
||||
{saving ? t("form.saving") : isEdit ? t("form.save") : t("form.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { WebhookKind } from "@/types/webhook";
|
||||
|
||||
export const NONE = "__none__";
|
||||
|
||||
interface AgentOption {
|
||||
id: string;
|
||||
display_name?: string | null;
|
||||
agent_key: string;
|
||||
}
|
||||
|
||||
interface ChannelOption {
|
||||
id: string;
|
||||
display_name?: string | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isEdit: boolean;
|
||||
isStandard: boolean;
|
||||
name: string;
|
||||
setName: (value: string) => void;
|
||||
kind: WebhookKind;
|
||||
setKind: (kind: WebhookKind) => void;
|
||||
agentId: string;
|
||||
setAgentId: (id: string) => void;
|
||||
channelId: string;
|
||||
setChannelId: (id: string) => void;
|
||||
rateLimit: string;
|
||||
setRateLimit: (value: string) => void;
|
||||
ipAllowlist: string;
|
||||
setIpAllowlist: (value: string) => void;
|
||||
requireHmac: boolean;
|
||||
setRequireHmac: (value: boolean) => void;
|
||||
localhostOnly: boolean;
|
||||
setLocalhostOnly: (value: boolean) => void;
|
||||
agents: AgentOption[];
|
||||
instances: ChannelOption[];
|
||||
}
|
||||
|
||||
export function WebhookFormFields({
|
||||
isEdit,
|
||||
isStandard,
|
||||
name,
|
||||
setName,
|
||||
kind,
|
||||
setKind,
|
||||
agentId,
|
||||
setAgentId,
|
||||
channelId,
|
||||
setChannelId,
|
||||
rateLimit,
|
||||
setRateLimit,
|
||||
ipAllowlist,
|
||||
setIpAllowlist,
|
||||
requireHmac,
|
||||
setRequireHmac,
|
||||
localhostOnly,
|
||||
setLocalhostOnly,
|
||||
agents,
|
||||
instances,
|
||||
}: Props) {
|
||||
const { t } = useTranslation("webhooks");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wh-name">{t("form.name")}</Label>
|
||||
<Input
|
||||
id="wh-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("form.namePlaceholder")}
|
||||
maxLength={100}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wh-kind">{t("form.kind")}</Label>
|
||||
<Select value={kind} onValueChange={(v) => setKind(v as WebhookKind)} disabled={isEdit}>
|
||||
<SelectTrigger id="wh-kind" className="text-base md:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="llm">{t("kind.llm")}</SelectItem>
|
||||
<SelectItem value="message" disabled={!isStandard}>
|
||||
{t("kind.message")}
|
||||
{!isStandard ? ` - ${t("form.messageStandardOnly")}` : ""}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{kind === "llm" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wh-agent">{t("form.agent")}</Label>
|
||||
<Select value={agentId} onValueChange={setAgentId} disabled={isEdit}>
|
||||
<SelectTrigger id="wh-agent" className="text-base md:text-sm">
|
||||
<SelectValue placeholder={t("form.agentPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>{t("form.none")}</SelectItem>
|
||||
{agents.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.display_name || a.agent_key}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{t("form.agentHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind === "message" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wh-channel">{t("form.channel")}</Label>
|
||||
<Select value={channelId} onValueChange={setChannelId}>
|
||||
<SelectTrigger id="wh-channel" className="text-base md:text-sm">
|
||||
<SelectValue placeholder={t("form.channelPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>{t("form.channelAny")}</SelectItem>
|
||||
{instances.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.display_name || c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wh-rate">{t("form.rateLimit")}</Label>
|
||||
<Input
|
||||
id="wh-rate"
|
||||
type="number"
|
||||
min={0}
|
||||
value={rateLimit}
|
||||
onChange={(e) => setRateLimit(e.target.value)}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wh-ips">{t("form.ipAllowlist")}</Label>
|
||||
<Input
|
||||
id="wh-ips"
|
||||
value={ipAllowlist}
|
||||
onChange={(e) => setIpAllowlist(e.target.value)}
|
||||
placeholder={t("form.ipAllowlistPlaceholder")}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("form.ipAllowlistHint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="wh-hmac">{t("form.requireHmac")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("form.requireHmacHint")}</p>
|
||||
</div>
|
||||
<Switch id="wh-hmac" checked={requireHmac} onCheckedChange={setRequireHmac} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="wh-localhost">{t("form.localhostOnly")}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isStandard ? t("form.localhostOnlyHint") : t("form.localhostOnlyLite")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="wh-localhost"
|
||||
checked={localhostOnly}
|
||||
onCheckedChange={setLocalhostOnly}
|
||||
disabled={!isStandard}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Ban,
|
||||
Bot,
|
||||
Check,
|
||||
Copy,
|
||||
History,
|
||||
KeyRound,
|
||||
Pencil,
|
||||
Play,
|
||||
Radio,
|
||||
Webhook as WebhookIcon,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
import type { WebhookData } from "@/types/webhook";
|
||||
|
||||
interface Props {
|
||||
webhooks: WebhookData[];
|
||||
copiedId: string | null;
|
||||
onCopyId: (id: string) => void;
|
||||
agentName: (id?: string) => string;
|
||||
channelName: (id?: string) => string;
|
||||
onTest: (webhook: WebhookData) => void;
|
||||
onCalls: (webhook: WebhookData) => void;
|
||||
onEdit: (webhook: WebhookData) => void;
|
||||
onRotate: (webhook: WebhookData) => void;
|
||||
onRevoke: (webhook: WebhookData) => void;
|
||||
}
|
||||
|
||||
export function WebhookListTable({
|
||||
webhooks,
|
||||
copiedId,
|
||||
onCopyId,
|
||||
agentName,
|
||||
channelName,
|
||||
onTest,
|
||||
onCalls,
|
||||
onEdit,
|
||||
onRotate,
|
||||
onRevoke,
|
||||
}: Props) {
|
||||
const { t } = useTranslation("webhooks");
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full min-w-[800px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.name")}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.webhookId")}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.kind")}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.target")}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.status")}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.lastUsed")}</th>
|
||||
<th className="px-4 py-3 text-right font-medium">{t("columns.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{webhooks.map((w) => (
|
||||
<tr key={w.id} className={`border-b last:border-0 hover:bg-muted/30 ${w.revoked ? "opacity-50" : ""}`}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<WebhookIcon className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div className="font-medium">{w.name}</div>
|
||||
<code className="text-xs-plus text-muted-foreground font-mono">{w.secret_prefix}...</code>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="text-xs font-mono text-muted-foreground" title={w.id}>
|
||||
{w.id.slice(0, 8)}...
|
||||
</code>
|
||||
<button
|
||||
onClick={() => onCopyId(w.id)}
|
||||
className="ml-1 rounded p-0.5 hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={w.id}
|
||||
>
|
||||
{copiedId === w.id ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="secondary" className="text-xs">{t(`kind.${w.kind}`)}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{w.kind === "llm" && w.agent_id && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
{agentName(w.agent_id)}
|
||||
</span>
|
||||
)}
|
||||
{w.kind === "message" && w.channel_id && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Radio className="h-3.5 w-3.5" />
|
||||
{channelName(w.channel_id)}
|
||||
</span>
|
||||
)}
|
||||
{!w.agent_id && !w.channel_id && "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{w.revoked ? (
|
||||
<Badge variant="destructive" className="text-xs">{t("status.revoked")}</Badge>
|
||||
) : (
|
||||
<Badge variant="default" className="text-xs">{t("status.active")}</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground" title={w.last_used_at ? new Date(w.last_used_at).toLocaleString() : undefined}>
|
||||
{w.last_used_at ? formatRelativeTime(w.last_used_at) : t("neverUsed")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{!w.revoked && (w.kind === "llm" || w.kind === "message") && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onTest(w)} title={t("actions.test")}>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => onCalls(w)} title={t("actions.calls")}>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{!w.revoked && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => onEdit(w)} title={t("actions.edit")}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => onRotate(w)} title={t("actions.rotate")}>
|
||||
<KeyRound className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRevoke(w)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
title={t("actions.revoke")}
|
||||
>
|
||||
<Ban className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, Check, ShieldAlert } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export interface WebhookSecretPayload {
|
||||
webhookId: string;
|
||||
secret: string;
|
||||
hmacSigningKey: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
payload: WebhookSecretPayload | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function WebhookSecretDialog({ payload, onClose }: Props) {
|
||||
const { t } = useTranslation("webhooks");
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
|
||||
const copy = async (value: string, field: string) => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopiedKey(field);
|
||||
setTimeout(() => setCopiedKey((k) => (k === field ? null : k)), 2000);
|
||||
};
|
||||
|
||||
const Row = ({ label, value, field }: { label: string; value: string; field: string }) => (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{label}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 overflow-x-auto rounded bg-muted px-3 py-2 text-base md:text-sm font-mono break-all">
|
||||
{value}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={() => copy(value, field)} className="gap-1 shrink-0">
|
||||
{copiedKey === field ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copiedKey === field ? t("secret.copied") : t("secret.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={!!payload} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-sm:inset-0 max-sm:translate-x-0 max-sm:translate-y-0 sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("secret.title")}</DialogTitle>
|
||||
<DialogDescription>{t("secret.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm">
|
||||
<ShieldAlert className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
|
||||
<span>{t("secret.warning")}</span>
|
||||
</div>
|
||||
|
||||
{payload && (
|
||||
<div className="space-y-4">
|
||||
<Row label={t("secret.webhookId")} value={payload.webhookId} field="webhookId" />
|
||||
<Row label={t("secret.bearer")} value={payload.secret} field="secret" />
|
||||
<Row label={t("secret.hmacKey")} value={payload.hmacSigningKey} field="hmac" />
|
||||
<p className="text-xs text-muted-foreground">{t("secret.usageHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>{t("secret.done")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Play, AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import type {
|
||||
WebhookData,
|
||||
WebhookTestInput,
|
||||
WebhookTestResult,
|
||||
WebhookTestLLMResult,
|
||||
WebhookTestMessageResult,
|
||||
} from "@/types/webhook";
|
||||
|
||||
interface Props {
|
||||
webhook: WebhookData | null;
|
||||
onClose: () => void;
|
||||
onRun: (id: string, body: WebhookTestInput) => Promise<WebhookTestResult>;
|
||||
}
|
||||
|
||||
function isLLMResult(r: WebhookTestResult): r is WebhookTestLLMResult {
|
||||
return "output" in r;
|
||||
}
|
||||
|
||||
export function WebhookTestDialog({ webhook, onClose, onRun }: Props) {
|
||||
const { t } = useTranslation("webhooks");
|
||||
const [input, setInput] = useState("");
|
||||
const [channelName, setChannelName] = useState("");
|
||||
const [chatId, setChatId] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [running, setRunning] = useState(false);
|
||||
const [result, setResult] = useState<WebhookTestResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (webhook) {
|
||||
setInput("");
|
||||
setChannelName("");
|
||||
setChatId("");
|
||||
setContent("");
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [webhook]);
|
||||
|
||||
const isMessage = webhook?.kind === "message";
|
||||
const hasBoundChannel = !!webhook?.channel_id;
|
||||
|
||||
const handleRun = async () => {
|
||||
if (!webhook) return;
|
||||
setRunning(true);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
try {
|
||||
const body: WebhookTestInput = isMessage
|
||||
? {
|
||||
channel_name: hasBoundChannel ? undefined : channelName.trim(),
|
||||
chat_id: chatId.trim(),
|
||||
content: content.trim(),
|
||||
}
|
||||
: { input: input.trim() };
|
||||
const res = await onRun(webhook.id, body);
|
||||
setResult(res);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : t("test.failed");
|
||||
setError(msg);
|
||||
toast.error(t("test.failed"), msg);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canRun = isMessage
|
||||
? chatId.trim() !== "" && content.trim() !== "" && (hasBoundChannel || channelName.trim() !== "")
|
||||
: input.trim() !== "";
|
||||
|
||||
return (
|
||||
<Dialog open={!!webhook} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-sm:inset-0 max-sm:translate-x-0 max-sm:translate-y-0 sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("test.title", { name: webhook?.name ?? "" })}</DialogTitle>
|
||||
<DialogDescription>{t("test.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isMessage && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
|
||||
<span>{t("test.messageWarning")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{isMessage ? (
|
||||
<>
|
||||
{!hasBoundChannel && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="test-channel">{t("test.channelName")}</Label>
|
||||
<Input
|
||||
id="test-channel"
|
||||
value={channelName}
|
||||
onChange={(e) => setChannelName(e.target.value)}
|
||||
placeholder={t("test.channelNamePlaceholder")}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="test-chat">{t("test.chatId")}</Label>
|
||||
<Input
|
||||
id="test-chat"
|
||||
value={chatId}
|
||||
onChange={(e) => setChatId(e.target.value)}
|
||||
placeholder={t("test.chatIdPlaceholder")}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="test-content">{t("test.content")}</Label>
|
||||
<Textarea
|
||||
id="test-content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={3}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="test-input">{t("test.input")}</Label>
|
||||
<Textarea
|
||||
id="test-input"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={t("test.inputPlaceholder")}
|
||||
rows={4}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive break-words">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("test.result")}</Label>
|
||||
{isLLMResult(result) ? (
|
||||
<pre className="max-h-60 overflow-auto rounded bg-muted px-3 py-2 text-xs whitespace-pre-wrap break-words">
|
||||
{result.output}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="rounded bg-muted px-3 py-2 text-sm">
|
||||
{t("test.sent", {
|
||||
channel: (result as WebhookTestMessageResult).channel_name,
|
||||
chatId: (result as WebhookTestMessageResult).chat_id,
|
||||
})}
|
||||
{(result as WebhookTestMessageResult).warning && (
|
||||
<p className="text-xs text-amber-600 mt-1">
|
||||
{(result as WebhookTestMessageResult).warning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("test.close")}
|
||||
</Button>
|
||||
<Button type="button" onClick={handleRun} disabled={running || !canRun} className="gap-1">
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
{running ? t("test.running") : t("test.run")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, RefreshCw, Webhook as WebhookIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { EmptyState } from "@/components/shared/empty-state";
|
||||
import { SearchInput } from "@/components/shared/search-input";
|
||||
import { TableSkeleton } from "@/components/shared/loading-skeleton";
|
||||
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
import { useAgents } from "@/pages/agents/hooks/use-agents";
|
||||
import { useChannelInstances } from "@/pages/channels/hooks/use-channel-instances";
|
||||
import { useWebhooks } from "./hooks/use-webhooks";
|
||||
import { WebhookListTable } from "./webhook-list-table";
|
||||
import { WebhookFormDialog } from "./webhook-form-dialog";
|
||||
import { WebhookSecretDialog, type WebhookSecretPayload } from "./webhook-secret-dialog";
|
||||
import { WebhookTestDialog } from "./webhook-test-dialog";
|
||||
import { WebhookCallsDialog } from "./webhook-calls-dialog";
|
||||
import type { WebhookData } from "@/types/webhook";
|
||||
|
||||
export function WebhooksPage() {
|
||||
const { t } = useTranslation("webhooks");
|
||||
const { t: tc } = useTranslation("common");
|
||||
const { webhooks, loading, refresh, createWebhook, updateWebhook, rotateSecret, revokeWebhook, testWebhook } = useWebhooks();
|
||||
const { agents } = useAgents();
|
||||
const { instances } = useChannelInstances({ limit: 200 });
|
||||
|
||||
const spinning = useMinLoading(loading);
|
||||
const showSkeleton = useDeferredLoading(loading && webhooks.length === 0);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [showRevoked, setShowRevoked] = useState(false);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<WebhookData | null>(null);
|
||||
const [secret, setSecret] = useState<WebhookSecretPayload | null>(null);
|
||||
const [testTarget, setTestTarget] = useState<WebhookData | null>(null);
|
||||
const [callsTarget, setCallsTarget] = useState<WebhookData | null>(null);
|
||||
const [revokeTarget, setRevokeTarget] = useState<WebhookData | null>(null);
|
||||
const [rotateTarget, setRotateTarget] = useState<WebhookData | null>(null);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const copyId = async (id: string) => {
|
||||
await navigator.clipboard.writeText(id);
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId((v) => (v === id ? null : v)), 2000);
|
||||
};
|
||||
|
||||
const agentName = (id?: string) => (id ? agents.find((a) => a.id === id)?.display_name || agents.find((a) => a.id === id)?.agent_key || id.slice(0, 8) : "");
|
||||
const channelName = (id?: string) => (id ? instances.find((c) => c.id === id)?.display_name || instances.find((c) => c.id === id)?.name || id.slice(0, 8) : "");
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
webhooks
|
||||
.filter((w) => (showRevoked ? true : !w.revoked))
|
||||
.filter((w) => w.name.toLowerCase().includes(search.toLowerCase()) || w.secret_prefix.includes(search)),
|
||||
[webhooks, showRevoked, search],
|
||||
);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
};
|
||||
const openEdit = (w: WebhookData) => {
|
||||
setEditing(w);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleRevoke = async () => {
|
||||
if (!revokeTarget) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await revokeWebhook(revokeTarget.id);
|
||||
setRevokeTarget(null);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRotate = async () => {
|
||||
if (!rotateTarget) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const res = await rotateSecret(rotateTarget.id);
|
||||
const rid = rotateTarget.id;
|
||||
setRotateTarget(null);
|
||||
setSecret({ webhookId: rid, secret: res.secret, hmacSigningKey: res.hmac_signing_key });
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 pb-10">
|
||||
<PageHeader
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={openCreate} className="gap-1">
|
||||
<Plus className="h-3.5 w-3.5" /> {t("addWebhook")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={refresh} disabled={spinning} className="gap-1">
|
||||
<RefreshCw className={spinning ? "animate-spin h-3.5 w-3.5" : "h-3.5 w-3.5"} /> {tc("refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-4">
|
||||
<SearchInput value={search} onChange={setSearch} placeholder={t("searchPlaceholder")} className="max-w-sm" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch id="show-revoked" checked={showRevoked} onCheckedChange={setShowRevoked} />
|
||||
<Label htmlFor="show-revoked" className="text-sm text-muted-foreground">{t("showRevoked")}</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{showSkeleton ? (
|
||||
<TableSkeleton rows={5} />
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState icon={WebhookIcon} title={t("emptyTitle")} description={t("emptyDescription")} />
|
||||
) : (
|
||||
<WebhookListTable
|
||||
webhooks={filtered}
|
||||
copiedId={copiedId}
|
||||
onCopyId={copyId}
|
||||
agentName={agentName}
|
||||
channelName={channelName}
|
||||
onTest={setTestTarget}
|
||||
onCalls={setCallsTarget}
|
||||
onEdit={openEdit}
|
||||
onRotate={setRotateTarget}
|
||||
onRevoke={setRevokeTarget}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<WebhookFormDialog
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
editing={editing}
|
||||
onCreate={async (input) => {
|
||||
const res = await createWebhook(input);
|
||||
setSecret({ webhookId: res.id, secret: res.secret, hmacSigningKey: res.hmac_signing_key });
|
||||
}}
|
||||
onUpdate={updateWebhook}
|
||||
/>
|
||||
|
||||
<WebhookSecretDialog payload={secret} onClose={() => setSecret(null)} />
|
||||
|
||||
<WebhookTestDialog webhook={testTarget} onClose={() => setTestTarget(null)} onRun={testWebhook} />
|
||||
|
||||
<WebhookCallsDialog webhook={callsTarget} onClose={() => setCallsTarget(null)} />
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
open={!!revokeTarget}
|
||||
onOpenChange={(v) => !v && setRevokeTarget(null)}
|
||||
title={t("revoke.title")}
|
||||
description={t("revoke.description", { name: revokeTarget?.name })}
|
||||
confirmValue={revokeTarget?.name || ""}
|
||||
confirmLabel={t("actions.revoke")}
|
||||
onConfirm={handleRevoke}
|
||||
loading={actionLoading}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
open={!!rotateTarget}
|
||||
onOpenChange={(v) => !v && setRotateTarget(null)}
|
||||
title={t("rotate.title")}
|
||||
description={t("rotate.description", { name: rotateTarget?.name })}
|
||||
confirmValue={rotateTarget?.name || ""}
|
||||
confirmLabel={t("actions.rotate")}
|
||||
onConfirm={handleRotate}
|
||||
loading={actionLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,6 +114,9 @@ const BackupRestorePage = lazyWithRetry(() =>
|
||||
const HooksPage = lazyWithRetry(() =>
|
||||
import("@/pages/hooks").then((m) => ({ default: m.HooksPage })),
|
||||
);
|
||||
const WebhooksPage = lazyWithRetry(() =>
|
||||
import("@/pages/webhooks/webhooks-page").then((m) => ({ default: m.WebhooksPage })),
|
||||
);
|
||||
const WorkstationsPage = lazyWithRetry(() =>
|
||||
import("@/pages/workstations/workstations-page").then((m) => ({ default: m.WorkstationsPage })),
|
||||
);
|
||||
@@ -185,6 +188,7 @@ export function AppRoutes() {
|
||||
<Route path={ROUTES.API_KEYS} element={<RequireAdmin><ApiKeysPage /></RequireAdmin>} />
|
||||
<Route path={ROUTES.CHANNELS} element={<RequireAdmin><ChannelsPage key="list" /></RequireAdmin>} />
|
||||
<Route path={ROUTES.CHANNEL_DETAIL} element={<RequireAdmin><ChannelsPage key="detail" /></RequireAdmin>} />
|
||||
<Route path={ROUTES.WEBHOOKS} element={<RequireAdmin><WebhooksPage /></RequireAdmin>} />
|
||||
<Route path={ROUTES.NODES} element={<RequireAdmin><NodesPage /></RequireAdmin>} />
|
||||
<Route path={ROUTES.WORKSTATIONS} element={<RequireAdmin><WorkstationsPage /></RequireAdmin>} />
|
||||
<Route path={ROUTES.LOGS} element={<RequireAdmin><LogsPage /></RequireAdmin>} />
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// Webhook registry + delivery-history types. Mirrors internal/store/webhook_store.go
|
||||
// (WebhookData / webhookCallResp) and internal/http/webhooks_admin.go request shapes.
|
||||
|
||||
export type WebhookKind = "llm" | "message";
|
||||
|
||||
export interface WebhookData {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
agent_id?: string;
|
||||
name: string;
|
||||
kind: WebhookKind;
|
||||
secret_prefix: string;
|
||||
scopes: string[];
|
||||
channel_id?: string;
|
||||
rate_limit_per_min: number;
|
||||
ip_allowlist: string[];
|
||||
require_hmac: boolean;
|
||||
localhost_only: boolean;
|
||||
revoked: boolean;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_used_at?: string;
|
||||
}
|
||||
|
||||
// Create payload — POST /v1/webhooks.
|
||||
export interface WebhookCreateInput {
|
||||
name: string;
|
||||
kind: WebhookKind;
|
||||
agent_id?: string;
|
||||
channel_id?: string;
|
||||
rate_limit_per_min?: number;
|
||||
ip_allowlist?: string[];
|
||||
require_hmac?: boolean;
|
||||
localhost_only?: boolean;
|
||||
}
|
||||
|
||||
// PATCH /v1/webhooks/{id} — kind/agent_id are immutable after create.
|
||||
export interface WebhookUpdateInput {
|
||||
name?: string;
|
||||
channel_id?: string;
|
||||
rate_limit_per_min?: number;
|
||||
ip_allowlist?: string[];
|
||||
require_hmac?: boolean;
|
||||
localhost_only?: boolean;
|
||||
}
|
||||
|
||||
// Response from create — secret + hmac_signing_key shown ONCE.
|
||||
export interface WebhookCreateResponse extends WebhookData {
|
||||
secret: string;
|
||||
hmac_signing_key: string;
|
||||
}
|
||||
|
||||
// Response from POST /v1/webhooks/{id}/rotate — secret shown ONCE.
|
||||
export interface WebhookRotateResponse {
|
||||
id: string;
|
||||
secret: string;
|
||||
hmac_signing_key: string;
|
||||
secret_prefix: string;
|
||||
}
|
||||
|
||||
// Delivery-history record — GET /v1/webhooks/{id}/calls (webhookCallResp DTO).
|
||||
export interface WebhookCallData {
|
||||
id: string;
|
||||
delivery_id: string;
|
||||
mode: "sync" | "async";
|
||||
status: "queued" | "running" | "done" | "failed" | "dead";
|
||||
attempts: number;
|
||||
next_attempt_at?: string;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
created_at: string;
|
||||
last_error?: string;
|
||||
response?: string;
|
||||
}
|
||||
|
||||
// Full delivery detail — GET /v1/webhooks/{id}/calls/{callId} (webhookCallDetailResp DTO).
|
||||
export interface WebhookCallDetail {
|
||||
id: string;
|
||||
webhook_id: string;
|
||||
agent_id?: string;
|
||||
delivery_id: string;
|
||||
idempotency_key?: string;
|
||||
mode: "sync" | "async";
|
||||
status: "queued" | "running" | "done" | "failed" | "dead";
|
||||
callback_url?: string;
|
||||
attempts: number;
|
||||
next_attempt_at?: string;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
created_at: string;
|
||||
last_error?: string;
|
||||
request_payload?: string;
|
||||
response?: string;
|
||||
}
|
||||
|
||||
// POST /v1/webhooks/{id}/test request — fields used depend on webhook kind.
|
||||
export interface WebhookTestInput {
|
||||
// llm
|
||||
input?: string;
|
||||
model?: string;
|
||||
// message
|
||||
channel_name?: string;
|
||||
chat_id?: string;
|
||||
content?: string;
|
||||
media_url?: string;
|
||||
media_caption?: string;
|
||||
fallback_to_text?: boolean;
|
||||
}
|
||||
|
||||
export interface WebhookTestLLMResult {
|
||||
call_id: string;
|
||||
agent_id: string;
|
||||
output: string;
|
||||
finish_reason: string;
|
||||
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
||||
}
|
||||
|
||||
export interface WebhookTestMessageResult {
|
||||
call_id: string;
|
||||
status: string;
|
||||
channel_name: string;
|
||||
chat_id: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export type WebhookTestResult = WebhookTestLLMResult | WebhookTestMessageResult;
|
||||
Reference in new issue
Block a user