mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
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:
1 parent
2945d4db6c
commit
06ef06ca37
22 files changed
+472
-54
No files matched your search
+20
-2
@@ -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}`
|
||||
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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,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,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": "下一页"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user