feat(webhooks): paginate list + call-history endpoints (server + web UI) (#1268)

Add total-count pagination to the webhook admin endpoints and the web UI.

Store:
- WebhookStore/WebhookCallStore gain Count; WebhookListFilter gains
  IncludeRevoked + Query (PG + SQLite, parameterized, tenant-scoped)

API:
- GET /v1/webhooks and GET /v1/webhooks/{id}/calls return
  {items, total, limit, offset} with server-side search + revoked filtering

Web UI:
- server-driven list pager + search/revoked filter; call-history dialog uses
  the real total (fixes the full-page "has more" boundary bug)
- i18n pager labels (en/vi/zh)

Tests: store pagination integration test; mock stores implement Count.
No schema migration (read-only COUNT).
This commit is contained in:
thotam authored and GitHub committed 2026-06-24 10:39:24 +07:00
1 parent 2945d4db6c
commit 06ef06ca37
22 files changed
+472 -54

No files matched your search

+20 -2
View File
@@ -95,9 +95,27 @@ Fields:
### List — `GET /v1/webhooks`
Query params: `agent_id=<uuid>` (optional filter).
Query params (all optional):
- `agent_id=<uuid>` — filter by bound agent.
- `q=<text>` — case-insensitive match on name, or prefix match on `secret_prefix`.
- `include_revoked=true` — include revoked webhooks (default: excluded).
- `limit` — page size (default 20, max 200).
- `offset` — page offset (default 0).
Returns array of webhook objects. `secret` and `hmac_signing_key` are **not** included.
Returns a paginated envelope. `secret` and `hmac_signing_key` are **not** included.
```json
{
"items": [ /* webhook objects */ ],
"total": 42,
"limit": 20,
"offset": 0
}
```
### List calls — `GET /v1/webhooks/{id}/calls`
Delivery history for a webhook. Query params (all optional): `status` (`queued`|`running`|`done`|`failed`|`dead`), `limit` (default 20, max 200), `offset`. Returns the same `{items, total, limit, offset}` envelope.
### Get — `GET /v1/webhooks/{id}`
+48 -5
View File
@@ -289,8 +289,11 @@ func (h *WebhooksAdminHandler) handleList(w http.ResponseWriter, r *http.Request
return
}
// Optional ?agent_id= filter.
var f store.WebhookListFilter
f := store.WebhookListFilter{
Query: r.URL.Query().Get("q"),
IncludeRevoked: r.URL.Query().Get("include_revoked") == "true",
Limit: webhookListDefaultLimit,
}
if agentIDStr := r.URL.Query().Get("agent_id"); agentIDStr != "" {
aid, err := uuid.Parse(agentIDStr)
if err != nil {
@@ -299,6 +302,19 @@ func (h *WebhooksAdminHandler) handleList(w http.ResponseWriter, r *http.Request
}
f.AgentID = &aid
}
if l := r.URL.Query().Get("limit"); l != "" {
if n, perr := strconv.Atoi(l); perr == nil && n > 0 {
if n > webhookListMaxLimit {
n = webhookListMaxLimit
}
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.webhooks.List(r.Context(), f)
if err != nil {
@@ -306,10 +322,21 @@ func (h *WebhooksAdminHandler) handleList(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "webhooks"))
return
}
total, err := h.webhooks.Count(r.Context(), f)
if err != nil {
slog.Error("webhook.admin.count_failed", "error", err)
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "webhooks"))
return
}
if rows == nil {
rows = []store.WebhookData{}
}
writeJSON(w, http.StatusOK, rows)
writeJSON(w, http.StatusOK, map[string]any{
"items": rows,
"total": total,
"limit": f.Limit,
"offset": f.Offset,
})
}
// --- Get ---
@@ -586,7 +613,12 @@ type webhookCallResp struct {
}
const (
webhookCallsDefaultLimit = 50
webhookListDefaultLimit = 20
webhookListMaxLimit = 200
)
const (
webhookCallsDefaultLimit = 20
webhookCallsMaxLimit = 200
)
@@ -647,6 +679,12 @@ func (h *WebhooksAdminHandler) handleListCalls(w http.ResponseWriter, r *http.Re
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "webhook calls"))
return
}
total, err := h.calls.Count(ctx, f)
if err != nil {
slog.Error("webhook.admin.count_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 {
@@ -665,7 +703,12 @@ func (h *WebhooksAdminHandler) handleListCalls(w http.ResponseWriter, r *http.Re
Response: string(c.Response),
})
}
writeJSON(w, http.StatusOK, out)
writeJSON(w, http.StatusOK, map[string]any{
"items": out,
"total": total,
"limit": f.Limit,
"offset": f.Offset,
})
}
// --- Get Call (single delivery detail) ---
+28 -8
View File
@@ -52,6 +52,14 @@ func (s *adminCallStore) List(ctx context.Context, f store.WebhookCallListFilter
return out, nil
}
func (s *adminCallStore) Count(ctx context.Context, f store.WebhookCallListFilter) (int, error) {
rows, err := s.List(ctx, f)
if err != nil {
return 0, err
}
return len(rows), nil
}
func (s *adminCallStore) GetByID(ctx context.Context, id uuid.UUID) (*store.WebhookCallData, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -141,12 +149,18 @@ func TestWebhookAdmin_ListCalls_Success(t *testing.T) {
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 {
var body struct {
Items []webhookCallResp `json:"items"`
Total int `json:"total"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got) != 2 {
t.Fatalf("want 2 calls for this webhook, got %d", len(got))
if len(body.Items) != 2 {
t.Fatalf("want 2 calls for this webhook, got %d", len(body.Items))
}
if body.Total != 2 {
t.Fatalf("want total 2, got %d", body.Total)
}
}
@@ -166,10 +180,16 @@ func TestWebhookAdmin_ListCalls_StatusFilter(t *testing.T) {
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)
var body struct {
Items []webhookCallResp `json:"items"`
Total int `json:"total"`
}
_ = json.Unmarshal(w.Body.Bytes(), &body)
if len(body.Items) != 1 || body.Items[0].Status != "failed" {
t.Fatalf("status filter failed: %+v", body.Items)
}
if body.Total != 1 {
t.Fatalf("want total 1, got %d", body.Total)
}
}
+14 -3
View File
@@ -89,6 +89,14 @@ func (s *adminWebhookStore) List(ctx context.Context, f store.WebhookListFilter)
return out, nil
}
func (s *adminWebhookStore) Count(ctx context.Context, f store.WebhookListFilter) (int, error) {
rows, err := s.List(ctx, f)
if err != nil {
return 0, err
}
return len(rows), nil
}
func (s *adminWebhookStore) Update(_ context.Context, id uuid.UUID, updates map[string]any) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -537,12 +545,15 @@ func TestWebhookAdmin_FullFlow_CreateListGetRotateRevoke(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("list: want 200, got %d: %s", w.Code, w.Body.String())
}
var rows []store.WebhookData
if err := json.NewDecoder(w.Body).Decode(&rows); err != nil {
var body struct {
Items []store.WebhookData `json:"items"`
Total int `json:"total"`
}
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("list decode: %v", err)
}
found := false
for _, row := range rows {
for _, row := range body.Items {
if row.ID == id {
found = true
}
+6
View File
@@ -78,6 +78,9 @@ func (s *stubWebhookStore) Create(_ context.Context, _ *store.WebhookData) error
func (s *stubWebhookStore) List(_ context.Context, _ store.WebhookListFilter) ([]store.WebhookData, error) {
return nil, nil
}
func (s *stubWebhookStore) Count(_ context.Context, _ store.WebhookListFilter) (int, error) {
return 0, nil
}
func (s *stubWebhookStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error {
return nil
}
@@ -126,6 +129,9 @@ func (s *stubWebhookCallStore) ClaimNext(_ context.Context, _ uuid.UUID, _ time.
func (s *stubWebhookCallStore) List(_ context.Context, _ store.WebhookCallListFilter) ([]store.WebhookCallData, error) {
return nil, nil
}
func (s *stubWebhookCallStore) Count(_ context.Context, _ store.WebhookCallListFilter) (int, error) {
return 0, nil
}
func (s *stubWebhookCallStore) DeleteOlderThan(_ context.Context, _ uuid.UUID, _ time.Time) (int64, error) {
return 0, nil
}
+3
View File
@@ -73,6 +73,9 @@ func (s *llmCallStore) ClaimNext(_ context.Context, _ uuid.UUID, _ time.Time) (*
func (s *llmCallStore) List(_ context.Context, _ store.WebhookCallListFilter) ([]store.WebhookCallData, error) {
return nil, nil
}
func (s *llmCallStore) Count(_ context.Context, _ store.WebhookCallListFilter) (int, error) {
return 0, nil
}
func (s *llmCallStore) DeleteOlderThan(_ context.Context, _ uuid.UUID, _ time.Time) (int64, error) {
return 0, nil
}
+6
View File
@@ -114,6 +114,9 @@ func (s *msgCallStore) ClaimNext(_ context.Context, _ uuid.UUID, _ time.Time) (*
func (s *msgCallStore) List(_ context.Context, _ store.WebhookCallListFilter) ([]store.WebhookCallData, error) {
return nil, nil
}
func (s *msgCallStore) Count(_ context.Context, _ store.WebhookCallListFilter) (int, error) {
return 0, nil
}
func (s *msgCallStore) DeleteOlderThan(_ context.Context, _ uuid.UUID, _ time.Time) (int64, error) {
return 0, nil
}
@@ -137,6 +140,9 @@ func (s *msgWebhookStore) GetByHash(_ context.Context, _ string) (*store.Webhook
func (s *msgWebhookStore) List(_ context.Context, _ store.WebhookListFilter) ([]store.WebhookData, error) {
return nil, nil
}
func (s *msgWebhookStore) Count(_ context.Context, _ store.WebhookListFilter) (int, error) {
return 0, nil
}
func (s *msgWebhookStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error {
return nil
}
+28
View File
@@ -222,6 +222,34 @@ func (s *PGWebhookCallStore) List(ctx context.Context, f store.WebhookCallListFi
return out, rows.Err()
}
func (s *PGWebhookCallStore) Count(ctx context.Context, f store.WebhookCallListFilter) (int, error) {
tid, err := requireTenantID(ctx)
if err != nil {
return 0, err
}
q := `SELECT COUNT(*) FROM webhook_calls WHERE tenant_id = $1`
args := []any{tid}
n := 2
if f.WebhookID != nil {
q += fmt.Sprintf(` AND webhook_id = $%d`, n)
args = append(args, *f.WebhookID)
n++
}
if f.Status != "" {
q += fmt.Sprintf(` AND status = $%d`, n)
args = append(args, f.Status)
n++
}
var total int
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&total); err != nil {
return 0, err
}
return total, nil
}
func (s *PGWebhookCallStore) DeleteOlderThan(ctx context.Context, tenantID uuid.UUID, ts time.Time) (int64, error) {
var res sql.Result
var err error
+39
View File
@@ -158,6 +158,14 @@ func (s *PGWebhookStore) List(ctx context.Context, f store.WebhookListFilter) ([
args = append(args, *f.AgentID)
n++
}
if !f.IncludeRevoked {
q += ` AND revoked = false`
}
if f.Query != "" {
q += fmt.Sprintf(` AND (name ILIKE $%d OR secret_prefix LIKE $%d)`, n, n+1)
args = append(args, "%"+f.Query+"%", f.Query+"%")
n += 2
}
q += ` ORDER BY created_at DESC`
limit := f.Limit
@@ -184,6 +192,37 @@ func (s *PGWebhookStore) List(ctx context.Context, f store.WebhookListFilter) ([
return out, rows.Err()
}
func (s *PGWebhookStore) Count(ctx context.Context, f store.WebhookListFilter) (int, error) {
tid, err := requireTenantID(ctx)
if err != nil {
return 0, err
}
q := `SELECT COUNT(*) FROM webhooks WHERE tenant_id = $1`
args := []any{tid}
n := 2
if f.AgentID != nil {
q += fmt.Sprintf(` AND agent_id = $%d`, n)
args = append(args, *f.AgentID)
n++
}
if !f.IncludeRevoked {
q += ` AND revoked = false`
}
if f.Query != "" {
q += fmt.Sprintf(` AND (name ILIKE $%d OR secret_prefix LIKE $%d)`, n, n+1)
args = append(args, "%"+f.Query+"%", f.Query+"%")
n += 2
}
var total int
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&total); err != nil {
return 0, err
}
return total, nil
}
func (s *PGWebhookStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
tid, err := requireTenantID(ctx)
if err != nil {
@@ -238,6 +238,31 @@ func (s *SQLiteWebhookCallStore) List(ctx context.Context, f store.WebhookCallLi
return out, rows.Err()
}
func (s *SQLiteWebhookCallStore) Count(ctx context.Context, f store.WebhookCallListFilter) (int, error) {
tid, err := requireTenantID(ctx)
if err != nil {
return 0, err
}
q := `SELECT COUNT(*) FROM webhook_calls WHERE tenant_id = ?`
args := []any{tid}
if f.WebhookID != nil {
q += ` AND webhook_id = ?`
args = append(args, *f.WebhookID)
}
if f.Status != "" {
q += ` AND status = ?`
args = append(args, f.Status)
}
var total int
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&total); err != nil {
return 0, err
}
return total, nil
}
func (s *SQLiteWebhookCallStore) DeleteOlderThan(ctx context.Context, tenantID uuid.UUID, ts time.Time) (int64, error) {
var res sql.Result
var err error
+35
View File
@@ -154,6 +154,13 @@ func (s *SQLiteWebhookStore) List(ctx context.Context, f store.WebhookListFilter
q += ` AND agent_id = ?`
args = append(args, *f.AgentID)
}
if !f.IncludeRevoked {
q += ` AND revoked = 0`
}
if f.Query != "" {
q += ` AND (name LIKE ? COLLATE NOCASE OR secret_prefix LIKE ?)`
args = append(args, "%"+f.Query+"%", f.Query+"%")
}
q += ` ORDER BY created_at DESC`
limit := f.Limit
@@ -180,6 +187,34 @@ func (s *SQLiteWebhookStore) List(ctx context.Context, f store.WebhookListFilter
return out, rows.Err()
}
func (s *SQLiteWebhookStore) Count(ctx context.Context, f store.WebhookListFilter) (int, error) {
tid, err := requireTenantID(ctx)
if err != nil {
return 0, err
}
q := `SELECT COUNT(*) FROM webhooks WHERE tenant_id = ?`
args := []any{tid}
if f.AgentID != nil {
q += ` AND agent_id = ?`
args = append(args, *f.AgentID)
}
if !f.IncludeRevoked {
q += ` AND revoked = 0`
}
if f.Query != "" {
q += ` AND (name LIKE ? COLLATE NOCASE OR secret_prefix LIKE ?)`
args = append(args, "%"+f.Query+"%", f.Query+"%")
}
var total int
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&total); err != nil {
return 0, err
}
return total, nil
}
func (s *SQLiteWebhookStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
tid, err := requireTenantID(ctx)
if err != nil {
+13 -5
View File
@@ -70,14 +70,16 @@ type WebhookCallData struct {
CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
}
// WebhookListFilter controls filtering for WebhookStore.List.
// WebhookListFilter controls filtering for WebhookStore.List / Count.
type WebhookListFilter struct {
AgentID *uuid.UUID // filter by bound agent (nil = all)
Limit int // 0 = default (50)
Offset int
AgentID *uuid.UUID // filter by bound agent (nil = all)
IncludeRevoked bool // false (default) excludes revoked = true
Query string // case-insensitive match on name OR prefix-match on secret_prefix ("" = no filter)
Limit int // 0 = default (50)
Offset int
}
// WebhookCallListFilter controls filtering for WebhookCallStore.List.
// WebhookCallListFilter controls filtering for WebhookCallStore.List / Count.
type WebhookCallListFilter struct {
WebhookID *uuid.UUID // filter by parent webhook (nil = all in tenant)
Status string // "" = all statuses
@@ -113,6 +115,9 @@ type WebhookStore interface {
// List returns webhooks for the context tenant, with optional agent filter.
List(ctx context.Context, f WebhookListFilter) ([]WebhookData, error)
// Count returns the total number of webhooks matching the filter (ignores Limit/Offset).
Count(ctx context.Context, f WebhookListFilter) (int, error)
// Update applies a partial update via column→value map.
// Caller validates keys; store validates against allowlist.
Update(ctx context.Context, id uuid.UUID, updates map[string]any) error
@@ -161,6 +166,9 @@ type WebhookCallStore interface {
// List returns calls for the context tenant with optional filters.
List(ctx context.Context, f WebhookCallListFilter) ([]WebhookCallData, error)
// Count returns the total number of calls matching the filter (ignores Limit/Offset).
Count(ctx context.Context, f WebhookCallListFilter) (int, error)
// DeleteOlderThan deletes terminal calls (done/failed/dead) older than ts.
// If tenantID is uuid.Nil, deletes across all tenants (retention worker).
DeleteOlderThan(ctx context.Context, tenantID uuid.UUID, ts time.Time) (int64, error)
+6
View File
@@ -94,6 +94,9 @@ func (s *stubCallStore) ClaimNext(_ context.Context, _ uuid.UUID, _ time.Time) (
func (s *stubCallStore) List(_ context.Context, _ store.WebhookCallListFilter) ([]store.WebhookCallData, error) {
return nil, nil
}
func (s *stubCallStore) Count(_ context.Context, _ store.WebhookCallListFilter) (int, error) {
return 0, nil
}
func (s *stubCallStore) DeleteOlderThan(_ context.Context, _ uuid.UUID, _ time.Time) (int64, error) {
return 0, nil
}
@@ -119,6 +122,9 @@ func (s *stubWebhookStore) GetByHash(_ context.Context, _ string) (*store.Webhoo
func (s *stubWebhookStore) List(_ context.Context, _ store.WebhookListFilter) ([]store.WebhookData, error) {
return nil, nil
}
func (s *stubWebhookStore) Count(_ context.Context, _ store.WebhookListFilter) (int, error) {
return 0, nil
}
func (s *stubWebhookStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error { return nil }
func (s *stubWebhookStore) RotateSecret(_ context.Context, _ uuid.UUID, _, _, _ string) error {
return nil
@@ -0,0 +1,85 @@
//go:build integration
package integration
import (
"context"
"crypto/sha256"
"encoding/hex"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
)
func TestWebhookListPaginationAndCount(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
s := pg.NewPGWebhookStore(db)
ctx := store.WithTenantID(context.Background(), tenantID)
t.Cleanup(func() {
db.Exec("DELETE FROM webhooks WHERE tenant_id = $1", tenantID)
})
mk := func(name string, revoked bool) {
id := uuid.New()
h := sha256.Sum256([]byte(id.String()))
wh := &store.WebhookData{
ID: id,
TenantID: tenantID,
Name: name,
Kind: "llm",
SecretPrefix: "wh_test",
SecretHash: hex.EncodeToString(h[:]),
Revoked: revoked,
}
if err := s.Create(ctx, wh); err != nil {
t.Fatalf("create %s: %v", name, err)
}
}
for i := 0; i < 5; i++ {
mk("active-"+uuid.NewString()[:8], false)
}
for i := 0; i < 2; i++ {
mk("revoked-"+uuid.NewString()[:8], true)
}
// Count default excludes revoked → 5.
if total, err := s.Count(ctx, store.WebhookListFilter{}); err != nil {
t.Fatal(err)
} else if total != 5 {
t.Fatalf("Count default = %d, want 5", total)
}
// Count including revoked → 7.
if total, err := s.Count(ctx, store.WebhookListFilter{IncludeRevoked: true}); err != nil {
t.Fatal(err)
} else if total != 7 {
t.Fatalf("Count includeRevoked = %d, want 7", total)
}
// Page 1 (limit 2, offset 0) → 2 rows.
if page1, err := s.List(ctx, store.WebhookListFilter{Limit: 2, Offset: 0}); err != nil {
t.Fatal(err)
} else if len(page1) != 2 {
t.Fatalf("page1 len = %d, want 2", len(page1))
}
// Page 3 (limit 2, offset 4) → 1 row (5 active total, revoked excluded).
if page3, err := s.List(ctx, store.WebhookListFilter{Limit: 2, Offset: 4}); err != nil {
t.Fatal(err)
} else if len(page3) != 1 {
t.Fatalf("page3 len = %d, want 1", len(page3))
}
// Query filter narrows Count to the 5 active "active-" rows.
if qc, err := s.Count(ctx, store.WebhookListFilter{Query: "active-"}); err != nil {
t.Fatal(err)
} else if qc != 5 {
t.Fatalf("query Count = %d, want 5", qc)
}
}
+7 -1
View File
@@ -7,6 +7,12 @@
"emptyTitle": "No webhooks",
"emptyDescription": "Create your first webhook to expose an agent or channel over HTTP.",
"neverUsed": "Never used",
"pager": {
"prev": "Previous",
"next": "Next",
"pageOf": "Page {{page}} of {{total}}",
"noMore": "No more webhooks."
},
"kind": {
"llm": "LLM",
"message": "Message"
@@ -111,7 +117,7 @@
"emptyTitle": "No deliveries yet",
"emptyDescription": "Calls to this webhook will appear here.",
"noMore": "No more calls.",
"page": "Page {{page}}",
"pageOf": "Page {{page}} of {{total}}",
"prev": "Previous",
"next": "Next"
},
+7 -1
View File
@@ -7,6 +7,12 @@
"emptyTitle": "Chưa có webhook",
"emptyDescription": "Tạo webhook đầu tiên để mở agent hoặc channel qua HTTP.",
"neverUsed": "Chưa dùng",
"pager": {
"prev": "Trước",
"next": "Sau",
"pageOf": "Trang {{page}} / {{total}}",
"noMore": "Không còn webhook nào."
},
"kind": {
"llm": "LLM",
"message": "Tin nhắn"
@@ -111,7 +117,7 @@
"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}}",
"pageOf": "Trang {{page}} / {{total}}",
"prev": "Trước",
"next": "Sau"
},
+7 -1
View File
@@ -7,6 +7,12 @@
"emptyTitle": "暂无 Webhook",
"emptyDescription": "创建第一个 Webhook,通过 HTTP 暴露智能体或频道。",
"neverUsed": "从未使用",
"pager": {
"prev": "上一页",
"next": "下一页",
"pageOf": "第 {{page}} 页 / 共 {{total}} 页",
"noMore": "没有更多 webhook。"
},
"kind": {
"llm": "LLM",
"message": "消息"
@@ -111,7 +117,7 @@
"emptyTitle": "尚无调用",
"emptyDescription": "对此 Webhook 的调用将显示在此处。",
"noMore": "没有更多调用。",
"page": "第 {{page}} 页",
"pageOf": "第 {{page}} 页 / 共 {{total}} 页",
"prev": "上一页",
"next": "下一页"
},
+1
View File
@@ -73,6 +73,7 @@ export const queryKeys = {
},
webhooks: {
all: ["webhooks"] as const,
list: (params: Record<string, unknown>) => ["webhooks", "list", params] 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,
},
@@ -14,15 +14,32 @@ import type {
WebhookCallDetail,
WebhookTestInput,
WebhookTestResult,
Paginated,
} from "@/types/webhook";
export function useWebhooks() {
export interface WebhookListParams {
limit: number;
offset: number;
q?: string;
includeRevoked?: boolean;
}
export function useWebhooks(params: WebhookListParams) {
const http = useHttp();
const queryClient = useQueryClient();
const { data: webhooks = [], isLoading: loading } = useQuery({
queryKey: queryKeys.webhooks.all,
queryFn: () => http.get<WebhookData[]>("/v1/webhooks"),
const { data, isLoading: loading } = useQuery({
queryKey: queryKeys.webhooks.list(params as unknown as Record<string, unknown>),
queryFn: () => {
const q: Record<string, string> = {
limit: String(params.limit),
offset: String(params.offset),
};
if (params.q) q.q = params.q;
if (params.includeRevoked) q.include_revoked = "true";
return http.get<Paginated<WebhookData>>("/v1/webhooks", q);
},
placeholderData: (prev) => prev,
staleTime: 60_000,
});
@@ -95,7 +112,17 @@ export function useWebhooks() {
[http],
);
return { webhooks, loading, refresh: invalidate, createWebhook, updateWebhook, rotateSecret, revokeWebhook, testWebhook };
return {
webhooks: data?.items ?? [],
total: data?.total ?? 0,
loading,
refresh: invalidate,
createWebhook,
updateWebhook,
rotateSecret,
revokeWebhook,
testWebhook,
};
}
export function useWebhookCalls(
@@ -108,19 +135,19 @@ export function useWebhookCalls(
const http = useHttp();
const params = { status, limit, offset };
const { data = [], isLoading: loading, isFetching, refetch } = useQuery({
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);
return http.get<Paginated<WebhookCallData>>(`/v1/webhooks/${id}/calls`, q);
},
enabled: enabled && !!id,
placeholderData: (prev) => prev,
staleTime: 10_000,
});
return { calls: data, loading, isFetching, refetch };
return { calls: data?.items ?? [], total: data?.total ?? 0, loading, isFetching, refetch };
}
export function useWebhookCallDetail(webhookId: string | null, callId: string | null) {
@@ -46,7 +46,7 @@ export function WebhookCallsDialog({ webhook, onClose }: Props) {
const [page, setPage] = useState(0);
const [detailCallId, setDetailCallId] = useState<string | null>(null);
const effectiveStatus = status === ALL ? "" : status;
const { calls, isFetching, refetch } = useWebhookCalls(
const { calls, total, isFetching, refetch } = useWebhookCalls(
webhook?.id ?? null,
effectiveStatus,
!!webhook,
@@ -59,8 +59,8 @@ export function WebhookCallsDialog({ webhook, onClose }: Props) {
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 totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const hasNext = (page + 1) * PAGE_SIZE < total;
const hasPrev = page > 0;
return (
@@ -146,7 +146,7 @@ export function WebhookCallsDialog({ webhook, onClose }: Props) {
{(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>
<span className="text-xs text-muted-foreground">{t("calls.pageOf", { page: page + 1, total: totalPages })}</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
+47 -16
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, RefreshCw, Webhook as WebhookIcon } from "lucide-react";
import { Plus, RefreshCw, Webhook as WebhookIcon, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
@@ -9,6 +9,7 @@ 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 { useDebounce } from "@/hooks/use-debounce";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { useAgents } from "@/pages/agents/hooks/use-agents";
@@ -24,15 +25,33 @@ 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 PAGE_SIZE = 20;
const [search, setSearch] = useState("");
const [showRevoked, setShowRevoked] = useState(false);
const [page, setPage] = useState(0);
const debouncedSearch = useDebounce(search, 300);
useEffect(() => {
setPage(0);
}, [debouncedSearch, showRevoked]);
const { webhooks, total, loading, refresh, createWebhook, updateWebhook, rotateSecret, revokeWebhook, testWebhook } =
useWebhooks({
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
q: debouncedSearch || undefined,
includeRevoked: showRevoked,
});
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 totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const hasPrev = page > 0;
const hasNext = (page + 1) * PAGE_SIZE < total;
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<WebhookData | null>(null);
const [secret, setSecret] = useState<WebhookSecretPayload | null>(null);
@@ -52,14 +71,6 @@ export function WebhooksPage() {
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);
@@ -121,11 +132,15 @@ export function WebhooksPage() {
<div className="mt-4">
{showSkeleton ? (
<TableSkeleton rows={5} />
) : filtered.length === 0 ? (
<EmptyState icon={WebhookIcon} title={t("emptyTitle")} description={t("emptyDescription")} />
) : webhooks.length === 0 ? (
page === 0 ? (
<EmptyState icon={WebhookIcon} title={t("emptyTitle")} description={t("emptyDescription")} />
) : (
<p className="py-8 text-center text-sm text-muted-foreground">{t("pager.noMore")}</p>
)
) : (
<WebhookListTable
webhooks={filtered}
webhooks={webhooks}
copiedId={copiedId}
onCopyId={copyId}
agentName={agentName}
@@ -137,6 +152,22 @@ export function WebhooksPage() {
onRevoke={setRevokeTarget}
/>
)}
{(hasPrev || hasNext) && (
<div className="mt-3 flex items-center justify-between border-t pt-3">
<span className="text-xs text-muted-foreground">
{t("pager.pageOf", { page: page + 1, total: totalPages })}
</span>
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={!hasPrev || spinning} className="gap-1">
<ChevronLeft className="h-3.5 w-3.5" /> {t("pager.prev")}
</Button>
<Button variant="outline" size="sm" onClick={() => setPage((p) => p + 1)} disabled={!hasNext || spinning} className="gap-1">
{t("pager.next")} <ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
</div>
<WebhookFormDialog
+8
View File
@@ -125,3 +125,11 @@ export interface WebhookTestMessageResult {
}
export type WebhookTestResult = WebhookTestLLMResult | WebhookTestMessageResult;
// Paginated list envelope — matches {items,total,limit,offset} from the admin handlers.
export interface Paginated<T> {
items: T[];
total: number;
limit: number;
offset: number;
}