diff --git a/docs/webhooks.md b/docs/webhooks.md index f3f78889..19e79c9e 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -95,9 +95,27 @@ Fields: ### List — `GET /v1/webhooks` -Query params: `agent_id=` (optional filter). +Query params (all optional): +- `agent_id=` — filter by bound agent. +- `q=` — 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}` diff --git a/internal/http/webhooks_admin.go b/internal/http/webhooks_admin.go index 64ac0354..4560286d 100644 --- a/internal/http/webhooks_admin.go +++ b/internal/http/webhooks_admin.go @@ -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) --- diff --git a/internal/http/webhooks_admin_calls_test.go b/internal/http/webhooks_admin_calls_test.go index 112bddea..dc5b0982 100644 --- a/internal/http/webhooks_admin_calls_test.go +++ b/internal/http/webhooks_admin_calls_test.go @@ -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) } } diff --git a/internal/http/webhooks_admin_test.go b/internal/http/webhooks_admin_test.go index b3839c89..da483862 100644 --- a/internal/http/webhooks_admin_test.go +++ b/internal/http/webhooks_admin_test.go @@ -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 } diff --git a/internal/http/webhooks_auth_test.go b/internal/http/webhooks_auth_test.go index bb9a6a0b..356c3b03 100644 --- a/internal/http/webhooks_auth_test.go +++ b/internal/http/webhooks_auth_test.go @@ -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 } diff --git a/internal/http/webhooks_llm_test.go b/internal/http/webhooks_llm_test.go index 0334b981..4513623b 100644 --- a/internal/http/webhooks_llm_test.go +++ b/internal/http/webhooks_llm_test.go @@ -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 } diff --git a/internal/http/webhooks_message_test.go b/internal/http/webhooks_message_test.go index 270c3ff9..059362dd 100644 --- a/internal/http/webhooks_message_test.go +++ b/internal/http/webhooks_message_test.go @@ -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 } diff --git a/internal/store/pg/webhook_calls.go b/internal/store/pg/webhook_calls.go index 7cdb996d..65ddcc9c 100644 --- a/internal/store/pg/webhook_calls.go +++ b/internal/store/pg/webhook_calls.go @@ -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 diff --git a/internal/store/pg/webhooks.go b/internal/store/pg/webhooks.go index 9bfb8bea..acd0f399 100644 --- a/internal/store/pg/webhooks.go +++ b/internal/store/pg/webhooks.go @@ -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 { diff --git a/internal/store/sqlitestore/webhook_calls.go b/internal/store/sqlitestore/webhook_calls.go index dddcf5ea..fe566286 100644 --- a/internal/store/sqlitestore/webhook_calls.go +++ b/internal/store/sqlitestore/webhook_calls.go @@ -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 diff --git a/internal/store/sqlitestore/webhooks.go b/internal/store/sqlitestore/webhooks.go index aae20081..0f4402d9 100644 --- a/internal/store/sqlitestore/webhooks.go +++ b/internal/store/sqlitestore/webhooks.go @@ -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 { diff --git a/internal/store/webhook_store.go b/internal/store/webhook_store.go index 3f6590e3..691dc633 100644 --- a/internal/store/webhook_store.go +++ b/internal/store/webhook_store.go @@ -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) diff --git a/internal/webhooks/worker_test.go b/internal/webhooks/worker_test.go index 3bc9c5d1..d38572d5 100644 --- a/internal/webhooks/worker_test.go +++ b/internal/webhooks/worker_test.go @@ -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 diff --git a/tests/integration/webhook_pagination_test.go b/tests/integration/webhook_pagination_test.go new file mode 100644 index 00000000..9476043c --- /dev/null +++ b/tests/integration/webhook_pagination_test.go @@ -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) + } +} diff --git a/ui/web/src/i18n/locales/en/webhooks.json b/ui/web/src/i18n/locales/en/webhooks.json index 4cf42dfe..92df993d 100644 --- a/ui/web/src/i18n/locales/en/webhooks.json +++ b/ui/web/src/i18n/locales/en/webhooks.json @@ -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" }, diff --git a/ui/web/src/i18n/locales/vi/webhooks.json b/ui/web/src/i18n/locales/vi/webhooks.json index 899bd3cc..68afa059 100644 --- a/ui/web/src/i18n/locales/vi/webhooks.json +++ b/ui/web/src/i18n/locales/vi/webhooks.json @@ -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" }, diff --git a/ui/web/src/i18n/locales/zh/webhooks.json b/ui/web/src/i18n/locales/zh/webhooks.json index 4c7effc8..98f3940d 100644 --- a/ui/web/src/i18n/locales/zh/webhooks.json +++ b/ui/web/src/i18n/locales/zh/webhooks.json @@ -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": "下一页" }, diff --git a/ui/web/src/lib/query-keys.ts b/ui/web/src/lib/query-keys.ts index c5dc15de..28f7759d 100644 --- a/ui/web/src/lib/query-keys.ts +++ b/ui/web/src/lib/query-keys.ts @@ -73,6 +73,7 @@ export const queryKeys = { }, webhooks: { all: ["webhooks"] as const, + list: (params: Record) => ["webhooks", "list", params] as const, calls: (id: string, params: Record) => ["webhooks", id, "calls", params] as const, call: (id: string, callId: string) => ["webhooks", id, "calls", callId] as const, }, diff --git a/ui/web/src/pages/webhooks/hooks/use-webhooks.ts b/ui/web/src/pages/webhooks/hooks/use-webhooks.ts index 5d24ec8b..2971ed1b 100644 --- a/ui/web/src/pages/webhooks/hooks/use-webhooks.ts +++ b/ui/web/src/pages/webhooks/hooks/use-webhooks.ts @@ -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("/v1/webhooks"), + const { data, isLoading: loading } = useQuery({ + queryKey: queryKeys.webhooks.list(params as unknown as Record), + queryFn: () => { + const q: Record = { + 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>("/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 = { limit: String(limit), offset: String(offset) }; if (status) q.status = status; - return http.get(`/v1/webhooks/${id}/calls`, q); + return http.get>(`/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) { diff --git a/ui/web/src/pages/webhooks/webhook-calls-dialog.tsx b/ui/web/src/pages/webhooks/webhook-calls-dialog.tsx index 210be879..e7f23214 100644 --- a/ui/web/src/pages/webhooks/webhook-calls-dialog.tsx +++ b/ui/web/src/pages/webhooks/webhook-calls-dialog.tsx @@ -46,7 +46,7 @@ export function WebhookCallsDialog({ webhook, onClose }: Props) { const [page, setPage] = useState(0); const [detailCallId, setDetailCallId] = useState(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) && (
- {t("calls.page", { page: page + 1 })} + {t("calls.pageOf", { page: page + 1, total: totalPages })}
+ +
+
+ )} { + items: T[]; + total: number; + limit: number; + offset: number; +}