forked from casdoor/casdoor
feat: add webhook delivery persistence, retry mechanism and replay UI (#5337)
This commit is contained in:
155
controllers/webhook_event.go
Normal file
155
controllers/webhook_event.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/beego/beego/v2/core/utils/pagination"
|
||||
"github.com/casdoor/casdoor/object"
|
||||
"github.com/casdoor/casdoor/util"
|
||||
)
|
||||
|
||||
const defaultWebhookEventListLimit = 100
|
||||
|
||||
// GetWebhookEvents
|
||||
// @Title GetWebhookEvents
|
||||
// @Tag Webhook Event API
|
||||
// @Description get webhook events with filtering
|
||||
// @Param owner query string false "The owner of webhook events"
|
||||
// @Param organization query string false "The organization"
|
||||
// @Param webhookName query string false "The webhook name"
|
||||
// @Param status query string false "Event status (pending, success, failed, retrying)"
|
||||
// @Success 200 {array} object.WebhookEvent The Response object
|
||||
// @router /get-webhook-events [get]
|
||||
func (c *ApiController) GetWebhookEvents() {
|
||||
owner := c.Ctx.Input.Query("owner")
|
||||
organization := c.Ctx.Input.Query("organization")
|
||||
webhookName := c.Ctx.Input.Query("webhookName")
|
||||
status := c.Ctx.Input.Query("status")
|
||||
limit := c.Ctx.Input.Query("pageSize")
|
||||
page := c.Ctx.Input.Query("p")
|
||||
|
||||
if limit != "" && page != "" {
|
||||
limit := util.ParseInt(limit)
|
||||
count, err := object.GetWebhookEventCount(owner, organization, webhookName, object.WebhookEventStatus(status))
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
paginator := pagination.NewPaginator(c.Ctx.Request, limit, count)
|
||||
events, err := object.GetWebhookEvents(owner, organization, webhookName, object.WebhookEventStatus(status), paginator.Offset(), limit)
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.ResponseOk(events, paginator.Nums())
|
||||
} else {
|
||||
events, err := object.GetWebhookEvents(owner, organization, webhookName, object.WebhookEventStatus(status), 0, defaultWebhookEventListLimit)
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.ResponseOk(events)
|
||||
}
|
||||
}
|
||||
|
||||
// GetWebhookEvent
|
||||
// @Title GetWebhookEvent
|
||||
// @Tag Webhook Event API
|
||||
// @Description get webhook event
|
||||
// @Param id query string true "The id ( owner/name ) of the webhook event"
|
||||
// @Success 200 {object} object.WebhookEvent The Response object
|
||||
// @router /get-webhook-event-detail [get]
|
||||
func (c *ApiController) GetWebhookEvent() {
|
||||
id := c.Ctx.Input.Query("id")
|
||||
|
||||
event, err := object.GetWebhookEvent(id)
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.ResponseOk(event)
|
||||
}
|
||||
|
||||
// ReplayWebhookEvent
|
||||
// @Title ReplayWebhookEvent
|
||||
// @Tag Webhook Event API
|
||||
// @Description replay a webhook event
|
||||
// @Param id query string true "The id ( owner/name ) of the webhook event"
|
||||
// @Success 200 {object} controllers.Response The Response object
|
||||
// @router /replay-webhook-event [post]
|
||||
func (c *ApiController) ReplayWebhookEvent() {
|
||||
id := c.Ctx.Input.Query("id")
|
||||
|
||||
err := object.ReplayWebhookEvent(id)
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.ResponseOk("Webhook event replayed successfully")
|
||||
}
|
||||
|
||||
// ReplayWebhookEvents
|
||||
// @Title ReplayWebhookEvents
|
||||
// @Tag Webhook Event API
|
||||
// @Description replay multiple webhook events
|
||||
// @Param owner query string false "The owner of webhook events"
|
||||
// @Param organization query string false "The organization"
|
||||
// @Param webhookName query string false "The webhook name"
|
||||
// @Param status query string false "Event status to replay (e.g., failed)"
|
||||
// @Success 200 {object} controllers.Response The Response object
|
||||
// @router /replay-webhook-events [post]
|
||||
func (c *ApiController) ReplayWebhookEvents() {
|
||||
owner := c.Ctx.Input.Query("owner")
|
||||
organization := c.Ctx.Input.Query("organization")
|
||||
webhookName := c.Ctx.Input.Query("webhookName")
|
||||
status := c.Ctx.Input.Query("status")
|
||||
|
||||
count, err := object.ReplayWebhookEvents(owner, organization, webhookName, object.WebhookEventStatus(status))
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.ResponseOk(map[string]interface{}{
|
||||
"count": count,
|
||||
"message": "webhook events replayed successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteWebhookEvent
|
||||
// @Title DeleteWebhookEvent
|
||||
// @Tag Webhook Event API
|
||||
// @Description delete webhook event
|
||||
// @Param body body object.WebhookEvent true "The details of the webhook event"
|
||||
// @Success 200 {object} controllers.Response The Response object
|
||||
// @router /delete-webhook-event [post]
|
||||
func (c *ApiController) DeleteWebhookEvent() {
|
||||
var event object.WebhookEvent
|
||||
err := json.Unmarshal(c.Ctx.Input.RequestBody, &event)
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.Data["json"] = wrapActionResponse(object.DeleteWebhookEvent(&event))
|
||||
c.ServeJSON()
|
||||
}
|
||||
3
main.go
3
main.go
@@ -132,6 +132,9 @@ func main() {
|
||||
go radius.StartRadiusServer()
|
||||
go object.ClearThroughputPerSecond()
|
||||
|
||||
// Start webhook delivery worker
|
||||
object.StartWebhookDeliveryWorker()
|
||||
|
||||
if len(object.SiteMap) != 0 {
|
||||
service.Start()
|
||||
}
|
||||
|
||||
@@ -433,6 +433,11 @@ func (a *Ormer) createTable() {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err = a.Engine.Sync2(new(WebhookEvent))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err = a.Engine.Sync2(new(VerificationRecord))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -332,27 +332,26 @@ func SendWebhooks(record *Record) error {
|
||||
if webhook.IsUserExtended {
|
||||
user, err = getUser(record.Organization, record.User)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
errs = append(errs, fmt.Errorf("webhook %s: failed to get user: %w", webhook.GetId(), err))
|
||||
continue
|
||||
}
|
||||
|
||||
user, err = GetMaskedUser(user, false, err)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
errs = append(errs, fmt.Errorf("webhook %s: failed to mask user: %w", webhook.GetId(), err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
statusCode, respBody, err := sendWebhook(webhook, &record2, user)
|
||||
// Create webhook event for tracking and retry
|
||||
_, err = CreateWebhookEventFromRecord(webhook, &record2, user)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
err = addWebhookRecord(webhook, &record2, statusCode, respBody, err)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
errs = append(errs, fmt.Errorf("webhook %s: failed to create event: %w", webhook.GetId(), err))
|
||||
continue
|
||||
}
|
||||
|
||||
// The webhook will be delivered by the background worker
|
||||
// This provides automatic retry and replay capability
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
|
||||
@@ -45,6 +45,11 @@ type Webhook struct {
|
||||
IsUserExtended bool `json:"isUserExtended"`
|
||||
SingleOrgOnly bool `json:"singleOrgOnly"`
|
||||
IsEnabled bool `json:"isEnabled"`
|
||||
|
||||
// Retry configuration
|
||||
MaxRetries int `xorm:"int default 3" json:"maxRetries"`
|
||||
RetryInterval int `xorm:"int default 60" json:"retryInterval"` // seconds
|
||||
UseExponentialBackoff bool `json:"useExponentialBackoff"`
|
||||
}
|
||||
|
||||
func GetWebhookCount(owner, organization, field, value string) (int64, error) {
|
||||
|
||||
260
object/webhook_event.go
Normal file
260
object/webhook_event.go
Normal file
@@ -0,0 +1,260 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package object
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/casdoor/casdoor/util"
|
||||
"github.com/xorm-io/core"
|
||||
)
|
||||
|
||||
// WebhookEventStatus represents the delivery status of a webhook event
|
||||
type WebhookEventStatus string
|
||||
|
||||
const (
|
||||
WebhookEventStatusPending WebhookEventStatus = "pending"
|
||||
WebhookEventStatusSuccess WebhookEventStatus = "success"
|
||||
WebhookEventStatusFailed WebhookEventStatus = "failed"
|
||||
WebhookEventStatusRetrying WebhookEventStatus = "retrying"
|
||||
)
|
||||
|
||||
// WebhookEvent represents a webhook delivery event with retry and replay capability
|
||||
type WebhookEvent struct {
|
||||
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
|
||||
Name string `xorm:"varchar(100) notnull pk" json:"name"`
|
||||
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
|
||||
UpdatedTime string `xorm:"varchar(100)" json:"updatedTime"`
|
||||
|
||||
WebhookName string `xorm:"varchar(200) index" json:"webhookName"`
|
||||
Organization string `xorm:"varchar(100) index" json:"organization"`
|
||||
EventType string `xorm:"varchar(100)" json:"eventType"`
|
||||
Status WebhookEventStatus `xorm:"varchar(50) index" json:"status"`
|
||||
|
||||
// Payload stores the event data (Record)
|
||||
Payload string `xorm:"mediumtext" json:"payload"`
|
||||
|
||||
// Extended user data if applicable
|
||||
ExtendedUser string `xorm:"mediumtext" json:"extendedUser"`
|
||||
|
||||
// Delivery tracking
|
||||
AttemptCount int `xorm:"int default 0" json:"attemptCount"`
|
||||
MaxRetries int `xorm:"int default 3" json:"maxRetries"`
|
||||
NextRetryTime string `xorm:"varchar(100)" json:"nextRetryTime"`
|
||||
|
||||
// Last delivery response
|
||||
LastStatusCode int `xorm:"int" json:"lastStatusCode"`
|
||||
LastResponse string `xorm:"mediumtext" json:"lastResponse"`
|
||||
LastError string `xorm:"mediumtext" json:"lastError"`
|
||||
}
|
||||
|
||||
func GetWebhookEvent(id string) (*WebhookEvent, error) {
|
||||
owner, name, err := util.GetOwnerAndNameFromIdWithError(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return getWebhookEvent(owner, name)
|
||||
}
|
||||
|
||||
func getWebhookEvent(owner string, name string) (*WebhookEvent, error) {
|
||||
if owner == "" || name == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
event := WebhookEvent{Owner: owner, Name: name}
|
||||
existed, err := ormer.Engine.Get(&event)
|
||||
if err != nil {
|
||||
return &event, err
|
||||
}
|
||||
|
||||
if existed {
|
||||
return &event, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func GetWebhookEvents(owner, organization, webhookName string, status WebhookEventStatus, offset, limit int) ([]*WebhookEvent, error) {
|
||||
events := []*WebhookEvent{}
|
||||
session := ormer.Engine.Desc("created_time")
|
||||
|
||||
if owner != "" {
|
||||
session = session.Where("owner = ?", owner)
|
||||
}
|
||||
if organization != "" {
|
||||
session = session.Where("organization = ?", organization)
|
||||
}
|
||||
if webhookName != "" {
|
||||
session = session.Where("webhook_name = ?", webhookName)
|
||||
}
|
||||
if status != "" {
|
||||
session = session.Where("status = ?", status)
|
||||
}
|
||||
|
||||
if offset > 0 {
|
||||
session = session.Limit(limit, offset)
|
||||
} else if limit > 0 {
|
||||
session = session.Limit(limit)
|
||||
}
|
||||
|
||||
err := session.Find(&events)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func GetWebhookEventCount(owner, organization, webhookName string, status WebhookEventStatus) (int64, error) {
|
||||
session := ormer.Engine.Where("1 = 1")
|
||||
|
||||
if owner != "" {
|
||||
session = session.Where("owner = ?", owner)
|
||||
}
|
||||
if organization != "" {
|
||||
session = session.Where("organization = ?", organization)
|
||||
}
|
||||
if webhookName != "" {
|
||||
session = session.Where("webhook_name = ?", webhookName)
|
||||
}
|
||||
if status != "" {
|
||||
session = session.Where("status = ?", status)
|
||||
}
|
||||
|
||||
return session.Count(&WebhookEvent{})
|
||||
}
|
||||
|
||||
func GetPendingWebhookEvents(limit int) ([]*WebhookEvent, error) {
|
||||
events := []*WebhookEvent{}
|
||||
currentTime := util.GetCurrentTime()
|
||||
|
||||
err := ormer.Engine.
|
||||
Where("status = ? OR status = ?", WebhookEventStatusPending, WebhookEventStatusRetrying).
|
||||
And("(next_retry_time = '' OR next_retry_time <= ?)", currentTime).
|
||||
Asc("created_time").
|
||||
Limit(limit).
|
||||
Find(&events)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func AddWebhookEvent(event *WebhookEvent) (bool, error) {
|
||||
if event.Name == "" {
|
||||
event.Name = util.GenerateId()
|
||||
}
|
||||
if event.CreatedTime == "" {
|
||||
event.CreatedTime = util.GetCurrentTime()
|
||||
}
|
||||
if event.UpdatedTime == "" {
|
||||
event.UpdatedTime = util.GetCurrentTime()
|
||||
}
|
||||
if event.Status == "" {
|
||||
event.Status = WebhookEventStatusPending
|
||||
}
|
||||
|
||||
affected, err := ormer.Engine.Insert(event)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return affected != 0, nil
|
||||
}
|
||||
|
||||
func UpdateWebhookEvent(id string, event *WebhookEvent) (bool, error) {
|
||||
owner, name, err := util.GetOwnerAndNameFromIdWithError(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
event.UpdatedTime = util.GetCurrentTime()
|
||||
|
||||
affected, err := ormer.Engine.ID(core.PK{owner, name}).AllCols().Update(event)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return affected != 0, nil
|
||||
}
|
||||
|
||||
func UpdateWebhookEventStatus(event *WebhookEvent, status WebhookEventStatus, statusCode int, response string, err error) (bool, error) {
|
||||
event.Status = status
|
||||
event.LastStatusCode = statusCode
|
||||
event.LastResponse = response
|
||||
event.UpdatedTime = util.GetCurrentTime()
|
||||
|
||||
if err != nil {
|
||||
event.LastError = err.Error()
|
||||
} else {
|
||||
event.LastError = ""
|
||||
}
|
||||
|
||||
affected, dbErr := ormer.Engine.ID(core.PK{event.Owner, event.Name}).
|
||||
Cols("status", "last_status_code", "last_response", "last_error", "updated_time", "attempt_count", "max_retries", "next_retry_time").
|
||||
Update(event)
|
||||
|
||||
if dbErr != nil {
|
||||
return false, dbErr
|
||||
}
|
||||
|
||||
return affected != 0, nil
|
||||
}
|
||||
|
||||
func DeleteWebhookEvent(event *WebhookEvent) (bool, error) {
|
||||
affected, err := ormer.Engine.ID(core.PK{event.Owner, event.Name}).Delete(&WebhookEvent{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return affected != 0, nil
|
||||
}
|
||||
|
||||
func (e *WebhookEvent) GetId() string {
|
||||
return fmt.Sprintf("%s/%s", e.Owner, e.Name)
|
||||
}
|
||||
|
||||
// CreateWebhookEventFromRecord creates a webhook event from a record
|
||||
func CreateWebhookEventFromRecord(webhook *Webhook, record *Record, extendedUser *User) (*WebhookEvent, error) {
|
||||
maxRetries := webhook.MaxRetries
|
||||
if maxRetries <= 0 {
|
||||
maxRetries = 3
|
||||
}
|
||||
|
||||
event := &WebhookEvent{
|
||||
Owner: webhook.Owner,
|
||||
Name: util.GenerateId(),
|
||||
CreatedTime: util.GetCurrentTime(),
|
||||
UpdatedTime: util.GetCurrentTime(),
|
||||
WebhookName: webhook.GetId(),
|
||||
Organization: record.Organization,
|
||||
EventType: record.Action,
|
||||
Status: WebhookEventStatusPending,
|
||||
Payload: util.StructToJson(record),
|
||||
AttemptCount: 0,
|
||||
MaxRetries: maxRetries,
|
||||
}
|
||||
|
||||
if extendedUser != nil {
|
||||
event.ExtendedUser = util.StructToJson(extendedUser)
|
||||
}
|
||||
|
||||
_, err := AddWebhookEvent(event)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return event, nil
|
||||
}
|
||||
263
object/webhook_worker.go
Normal file
263
object/webhook_worker.go
Normal file
@@ -0,0 +1,263 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package object
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/casdoor/casdoor/util"
|
||||
)
|
||||
|
||||
var (
|
||||
webhookWorkerMu sync.Mutex
|
||||
webhookWorkerRunning = false
|
||||
webhookWorkerStop chan struct{}
|
||||
webhookPollingInterval = 30 * time.Second // Configurable polling interval
|
||||
webhookBatchSize = 100 // Configurable batch size for processing events
|
||||
)
|
||||
|
||||
// StartWebhookDeliveryWorker starts the background worker for webhook delivery
|
||||
func StartWebhookDeliveryWorker() {
|
||||
webhookWorkerMu.Lock()
|
||||
defer webhookWorkerMu.Unlock()
|
||||
|
||||
if webhookWorkerRunning {
|
||||
return
|
||||
}
|
||||
|
||||
stopCh := make(chan struct{})
|
||||
webhookWorkerStop = stopCh
|
||||
webhookWorkerRunning = true
|
||||
|
||||
util.SafeGoroutine(func() {
|
||||
ticker := time.NewTicker(webhookPollingInterval)
|
||||
defer ticker.Stop()
|
||||
defer func() {
|
||||
webhookWorkerMu.Lock()
|
||||
defer webhookWorkerMu.Unlock()
|
||||
|
||||
if webhookWorkerStop == stopCh {
|
||||
webhookWorkerRunning = false
|
||||
webhookWorkerStop = nil
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
processWebhookEvents()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// StopWebhookDeliveryWorker stops the background worker
|
||||
func StopWebhookDeliveryWorker() {
|
||||
webhookWorkerMu.Lock()
|
||||
defer webhookWorkerMu.Unlock()
|
||||
|
||||
if !webhookWorkerRunning {
|
||||
return
|
||||
}
|
||||
|
||||
if webhookWorkerStop == nil {
|
||||
webhookWorkerRunning = false
|
||||
return
|
||||
}
|
||||
|
||||
close(webhookWorkerStop)
|
||||
webhookWorkerStop = nil
|
||||
webhookWorkerRunning = false
|
||||
}
|
||||
|
||||
// processWebhookEvents processes pending webhook events
|
||||
func processWebhookEvents() {
|
||||
events, err := GetPendingWebhookEvents(webhookBatchSize)
|
||||
if err != nil {
|
||||
fmt.Printf("Error getting pending webhook events: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, event := range events {
|
||||
deliverWebhookEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
// deliverWebhookEvent attempts to deliver a single webhook event
|
||||
func deliverWebhookEvent(event *WebhookEvent) {
|
||||
// Get the webhook configuration
|
||||
webhook, err := GetWebhook(event.WebhookName)
|
||||
if err != nil {
|
||||
fmt.Printf("Error getting webhook %s: %v\n", event.WebhookName, err)
|
||||
return
|
||||
}
|
||||
|
||||
if webhook == nil {
|
||||
// Webhook has been deleted, mark event as failed
|
||||
event.Status = WebhookEventStatusFailed
|
||||
event.LastError = "Webhook not found"
|
||||
UpdateWebhookEventStatus(event, WebhookEventStatusFailed, 0, "", fmt.Errorf("webhook not found"))
|
||||
return
|
||||
}
|
||||
|
||||
if !webhook.IsEnabled {
|
||||
// Webhook is disabled, skip for now
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the record from payload
|
||||
var record Record
|
||||
err = json.Unmarshal([]byte(event.Payload), &record)
|
||||
if err != nil {
|
||||
event.Status = WebhookEventStatusFailed
|
||||
event.LastError = fmt.Sprintf("Invalid payload: %v", err)
|
||||
UpdateWebhookEventStatus(event, WebhookEventStatusFailed, 0, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse extended user if present
|
||||
var extendedUser *User
|
||||
if event.ExtendedUser != "" {
|
||||
extendedUser = &User{}
|
||||
err = json.Unmarshal([]byte(event.ExtendedUser), extendedUser)
|
||||
if err != nil {
|
||||
fmt.Printf("Error parsing extended user: %v\n", err)
|
||||
extendedUser = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Increment attempt count
|
||||
event.AttemptCount++
|
||||
|
||||
// Attempt to send the webhook
|
||||
statusCode, respBody, err := sendWebhook(webhook, &record, extendedUser)
|
||||
|
||||
// Add webhook record for backward compatibility (only if non-200 status)
|
||||
if statusCode != 200 {
|
||||
addWebhookRecord(webhook, &record, statusCode, respBody, err)
|
||||
}
|
||||
|
||||
// Determine the result
|
||||
if err == nil && statusCode >= 200 && statusCode < 300 {
|
||||
// Success
|
||||
UpdateWebhookEventStatus(event, WebhookEventStatusSuccess, statusCode, respBody, nil)
|
||||
} else {
|
||||
// Failed - decide whether to retry
|
||||
maxRetries := event.MaxRetries
|
||||
if maxRetries <= 0 {
|
||||
maxRetries = webhook.MaxRetries
|
||||
}
|
||||
if maxRetries <= 0 {
|
||||
maxRetries = 3 // Default
|
||||
}
|
||||
event.MaxRetries = maxRetries
|
||||
|
||||
if event.AttemptCount >= maxRetries {
|
||||
// Max retries reached, mark as permanently failed
|
||||
UpdateWebhookEventStatus(event, WebhookEventStatusFailed, statusCode, respBody, err)
|
||||
} else {
|
||||
// Schedule retry
|
||||
retryInterval := webhook.RetryInterval
|
||||
if retryInterval <= 0 {
|
||||
retryInterval = 60 // Default 60 seconds
|
||||
}
|
||||
|
||||
nextRetryTime := calculateNextRetryTime(event.AttemptCount, retryInterval, webhook.UseExponentialBackoff)
|
||||
event.NextRetryTime = nextRetryTime
|
||||
event.Status = WebhookEventStatusRetrying
|
||||
|
||||
UpdateWebhookEventStatus(event, WebhookEventStatusRetrying, statusCode, respBody, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// calculateNextRetryTime calculates the next retry time based on attempt count and backoff strategy
|
||||
func calculateNextRetryTime(attemptCount int, baseInterval int, useExponentialBackoff bool) string {
|
||||
var delaySeconds int
|
||||
|
||||
if useExponentialBackoff {
|
||||
// Exponential backoff: baseInterval * 2^(attemptCount-1)
|
||||
// Cap attemptCount at 10 to prevent overflow
|
||||
cappedAttemptCount := attemptCount - 1
|
||||
if cappedAttemptCount > 10 {
|
||||
cappedAttemptCount = 10
|
||||
}
|
||||
|
||||
// Calculate delay with overflow protection
|
||||
delaySeconds = baseInterval * (1 << uint(cappedAttemptCount))
|
||||
|
||||
// Cap at 1 hour
|
||||
if delaySeconds > 3600 {
|
||||
delaySeconds = 3600
|
||||
}
|
||||
} else {
|
||||
// Fixed interval
|
||||
delaySeconds = baseInterval
|
||||
}
|
||||
|
||||
nextTime := time.Now().Add(time.Duration(delaySeconds) * time.Second)
|
||||
return nextTime.Format("2006-01-02T15:04:05Z07:00")
|
||||
}
|
||||
|
||||
// ReplayWebhookEvent replays a failed or missed webhook event
|
||||
func ReplayWebhookEvent(eventId string) error {
|
||||
event, err := GetWebhookEvent(eventId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if event == nil {
|
||||
return fmt.Errorf("webhook event not found: %s", eventId)
|
||||
}
|
||||
|
||||
// Reset the event for replay
|
||||
event.Status = WebhookEventStatusPending
|
||||
event.AttemptCount = 0
|
||||
event.NextRetryTime = ""
|
||||
event.LastError = ""
|
||||
|
||||
_, err = UpdateWebhookEvent(event.GetId(), event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Immediately try to deliver
|
||||
deliverWebhookEvent(event)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplayWebhookEvents replays multiple webhook events matching the criteria
|
||||
func ReplayWebhookEvents(owner, organization, webhookName string, status WebhookEventStatus) (int, error) {
|
||||
events, err := GetWebhookEvents(owner, organization, webhookName, status, 0, 0)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, event := range events {
|
||||
err = ReplayWebhookEvent(event.GetId())
|
||||
if err == nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
@@ -304,6 +304,13 @@ func InitAPI() {
|
||||
web.Router("/api/add-webhook", &controllers.ApiController{}, "POST:AddWebhook")
|
||||
web.Router("/api/delete-webhook", &controllers.ApiController{}, "POST:DeleteWebhook")
|
||||
|
||||
// Webhook event routes
|
||||
web.Router("/api/get-webhook-events", &controllers.ApiController{}, "GET:GetWebhookEvents")
|
||||
web.Router("/api/get-webhook-event-detail", &controllers.ApiController{}, "GET:GetWebhookEvent")
|
||||
web.Router("/api/replay-webhook-event", &controllers.ApiController{}, "POST:ReplayWebhookEvent")
|
||||
web.Router("/api/replay-webhook-events", &controllers.ApiController{}, "POST:ReplayWebhookEvents")
|
||||
web.Router("/api/delete-webhook-event", &controllers.ApiController{}, "POST:DeleteWebhookEvent")
|
||||
|
||||
web.Router("/api/get-tickets", &controllers.ApiController{}, "GET:GetTickets")
|
||||
web.Router("/api/get-ticket", &controllers.ApiController{}, "GET:GetTicket")
|
||||
web.Router("/api/update-ticket", &controllers.ApiController{}, "POST:UpdateTicket")
|
||||
|
||||
@@ -179,7 +179,7 @@ class App extends Component {
|
||||
"/servers", "/agents", "/sites", "/rules", // Gateway
|
||||
"/sessions", "/records", "/tokens", "/verifications", // Logging & Auditing
|
||||
"/products", "/orders", "/payments", "/plans", "/pricings", "/subscriptions", "/transactions", // Business
|
||||
"/sysinfo", "/forms", "/syncers", "/webhooks", "/tickets", "/swagger", // Admin
|
||||
"/sysinfo", "/forms", "/syncers", "/webhooks", "/webhook-events", "/tickets", "/swagger", // Admin
|
||||
];
|
||||
|
||||
const count = navItems.filter(item => validMenuItems.includes(item)).length;
|
||||
@@ -266,14 +266,16 @@ class App extends Component {
|
||||
} else if (uri.includes("/transactions")) {
|
||||
return "/transactions";
|
||||
}
|
||||
} else if (uri.includes("/sysinfo") || uri.includes("/forms") || uri.includes("/syncers") || uri.includes("/webhooks") || uri.includes("/tickets")) {
|
||||
} else if (uri.includes("/sysinfo") || uri.includes("/forms") || uri.includes("/syncers") || uri.includes("/webhooks") || uri.includes("/webhook-events") || uri.includes("/tickets")) {
|
||||
if (uri.includes("/sysinfo")) {
|
||||
return "/sysinfo";
|
||||
} else if (uri.includes("/forms")) {
|
||||
return "/forms";
|
||||
} else if (uri.includes("/syncers")) {
|
||||
return "/syncers";
|
||||
} else if (uri.includes("/webhooks")) {
|
||||
} else if (uri.includes("/webhook-events")) {
|
||||
return "/webhook-events";
|
||||
} else if (uri.includes("/webhooks") || uri.includes("/webhook-events")) {
|
||||
return "/webhooks";
|
||||
} else if (uri.includes("/tickets")) {
|
||||
return "/tickets";
|
||||
@@ -316,7 +318,7 @@ class App extends Component {
|
||||
this.setState({selectedMenuKey: "/logs"});
|
||||
} else if (uri.includes("/product-store") || uri.includes("/products") || uri.includes("/orders") || uri.includes("/payments") || uri.includes("/plans") || uri.includes("/pricings") || uri.includes("/subscriptions") || uri.includes("/transactions")) {
|
||||
this.setState({selectedMenuKey: "/business"});
|
||||
} else if (uri.includes("/sysinfo") || uri.includes("/forms") || uri.includes("/syncers") || uri.includes("/webhooks") || uri.includes("/tickets")) {
|
||||
} else if (uri.includes("/sysinfo") || uri.includes("/forms") || uri.includes("/syncers") || uri.includes("/webhooks") || uri.includes("/webhook-events") || uri.includes("/tickets")) {
|
||||
this.setState({selectedMenuKey: "/admin"});
|
||||
} else if (uri.includes("/signup")) {
|
||||
this.setState({selectedMenuKey: "/signup"});
|
||||
|
||||
@@ -85,6 +85,7 @@ import FormEditPage from "./FormEditPage";
|
||||
import SyncerListPage from "./SyncerListPage";
|
||||
import SyncerEditPage from "./SyncerEditPage";
|
||||
import WebhookListPage from "./WebhookListPage";
|
||||
import WebhookEventListPage from "./WebhookEventListPage";
|
||||
import WebhookEditPage from "./WebhookEditPage";
|
||||
import LdapEditPage from "./LdapEditPage";
|
||||
import LdapSyncPage from "./LdapSyncPage";
|
||||
@@ -385,6 +386,7 @@ function ManagementPage(props) {
|
||||
Setting.getItem(<Link to="/forms">{i18next.t("general:Forms")}</Link>, "/forms"),
|
||||
Setting.getItem(<Link to="/syncers">{i18next.t("general:Syncers")}</Link>, "/syncers"),
|
||||
Setting.getItem(<Link to="/webhooks">{i18next.t("general:Webhooks")}</Link>, "/webhooks"),
|
||||
Setting.getItem(<Link to="/webhook-events">{i18next.t("general:Webhook Events")}</Link>, "/webhook-events"),
|
||||
Setting.getItem(<Link to="/tickets">{i18next.t("general:Tickets")}</Link>, "/tickets"),
|
||||
Setting.getItem(<a target="_blank" rel="noreferrer" href={Setting.isLocalhost() ? `${Setting.ServerUrl}/swagger` : "/swagger"}>{i18next.t("general:Swagger")}</a>, "/swagger")]));
|
||||
} else {
|
||||
@@ -392,6 +394,7 @@ function ManagementPage(props) {
|
||||
Setting.getItem(<Link to="/forms">{i18next.t("general:Forms")}</Link>, "/forms"),
|
||||
Setting.getItem(<Link to="/syncers">{i18next.t("general:Syncers")}</Link>, "/syncers"),
|
||||
Setting.getItem(<Link to="/webhooks">{i18next.t("general:Webhooks")}</Link>, "/webhooks"),
|
||||
Setting.getItem(<Link to="/webhook-events">{i18next.t("general:Webhook Events")}</Link>, "/webhook-events"),
|
||||
Setting.getItem(<Link to="/tickets">{i18next.t("general:Tickets")}</Link>, "/tickets")]));
|
||||
}
|
||||
|
||||
@@ -546,6 +549,7 @@ function ManagementPage(props) {
|
||||
<Route exact path="/transactions" render={(props) => renderLoginIfNotLoggedIn(<TransactionListPage account={account} {...props} />)} />
|
||||
<Route exact path="/transactions/:organizationName/:transactionName" render={(props) => renderLoginIfNotLoggedIn(<TransactionEditPage account={account} {...props} />)} />
|
||||
<Route exact path="/webhooks" render={(props) => renderLoginIfNotLoggedIn(<WebhookListPage account={account} {...props} />)} />
|
||||
<Route exact path="/webhook-events" render={(props) => renderLoginIfNotLoggedIn(<WebhookEventListPage account={account} {...props} />)} />
|
||||
<Route exact path="/webhooks/:webhookName" render={(props) => renderLoginIfNotLoggedIn(<WebhookEditPage account={account} {...props} />)} />
|
||||
<Route exact path="/tickets" render={(props) => renderLoginIfNotLoggedIn(<TicketListPage account={account} {...props} />)} />
|
||||
<Route exact path="/tickets/:organizationName/:ticketName" render={(props) => renderLoginIfNotLoggedIn(<TicketEditPage account={account} {...props} />)} />
|
||||
|
||||
358
web/src/WebhookEventListPage.js
Normal file
358
web/src/WebhookEventListPage.js
Normal file
@@ -0,0 +1,358 @@
|
||||
// Copyright 2021 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from "react";
|
||||
import {Link} from "react-router-dom";
|
||||
import {Button, Descriptions, Drawer, Result, Table, Tag, Tooltip} from "antd";
|
||||
import i18next from "i18next";
|
||||
import * as Setting from "./Setting";
|
||||
import * as WebhookEventBackend from "./backend/WebhookEventBackend";
|
||||
import Editor from "./common/Editor";
|
||||
|
||||
class WebhookEventListPage extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
data: [],
|
||||
loading: false,
|
||||
replayingId: "",
|
||||
isAuthorized: true,
|
||||
detailShow: false,
|
||||
detailRecord: null,
|
||||
pagination: {
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
showQuickJumper: true,
|
||||
showSizeChanger: true,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener("storageOrganizationChanged", this.handleOrganizationChange);
|
||||
this.fetchWebhookEvents(this.state.pagination);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("storageOrganizationChanged", this.handleOrganizationChange);
|
||||
}
|
||||
|
||||
handleOrganizationChange = () => {
|
||||
const pagination = {
|
||||
...this.state.pagination,
|
||||
current: 1,
|
||||
};
|
||||
this.fetchWebhookEvents(pagination);
|
||||
};
|
||||
|
||||
getStatusTag = (status) => {
|
||||
const statusConfig = {
|
||||
pending: {color: "gold", text: i18next.t("webhook:Pending")},
|
||||
success: {color: "green", text: i18next.t("webhook:Success")},
|
||||
failed: {color: "red", text: i18next.t("webhook:Failed")},
|
||||
retrying: {color: "blue", text: i18next.t("webhook:Retrying")},
|
||||
};
|
||||
|
||||
const config = statusConfig[status] || {color: "default", text: status || i18next.t("webhook:Unknown")};
|
||||
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
getWebhookLink = (webhookName) => {
|
||||
if (!webhookName) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const shortName = Setting.getShortName(webhookName);
|
||||
|
||||
return (
|
||||
<Tooltip title={webhookName}>
|
||||
<Link to={`/webhooks/${encodeURIComponent(shortName)}`}>
|
||||
{shortName}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
getOrganizationFilter = () => {
|
||||
if (!this.props.account) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return Setting.isDefaultOrganizationSelected(this.props.account) ? "" : Setting.getRequestOrganization(this.props.account);
|
||||
};
|
||||
|
||||
fetchWebhookEvents = (pagination = this.state.pagination) => {
|
||||
this.setState({loading: true});
|
||||
|
||||
WebhookEventBackend.getWebhookEvents("", this.getOrganizationFilter(), pagination.current, pagination.pageSize)
|
||||
.then((res) => {
|
||||
this.setState({loading: false});
|
||||
|
||||
if (res.status === "ok") {
|
||||
this.setState({
|
||||
data: res.data || [],
|
||||
pagination: {
|
||||
...pagination,
|
||||
total: res.data2 ?? 0,
|
||||
},
|
||||
});
|
||||
} else if (Setting.isResponseDenied(res)) {
|
||||
this.setState({isAuthorized: false});
|
||||
} else {
|
||||
Setting.showMessage("error", res.msg);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setState({loading: false});
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
|
||||
});
|
||||
};
|
||||
|
||||
replayWebhookEvent = (event) => {
|
||||
const eventId = `${event.owner}/${event.name}`;
|
||||
this.setState({replayingId: eventId});
|
||||
|
||||
WebhookEventBackend.replayWebhookEvent(eventId)
|
||||
.then((res) => {
|
||||
this.setState({replayingId: ""});
|
||||
|
||||
if (res.status === "ok") {
|
||||
Setting.showMessage("success", typeof res.data === "string" ? res.data : i18next.t("webhook:Webhook event replayed successfully"));
|
||||
this.fetchWebhookEvents(this.state.pagination);
|
||||
} else {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to save")}: ${res.msg}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setState({replayingId: ""});
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
|
||||
});
|
||||
};
|
||||
|
||||
handleTableChange = (pagination) => {
|
||||
this.fetchWebhookEvents(pagination);
|
||||
};
|
||||
|
||||
openDetailDrawer = (record) => {
|
||||
this.setState({
|
||||
detailRecord: record,
|
||||
detailShow: true,
|
||||
});
|
||||
};
|
||||
|
||||
closeDetailDrawer = () => {
|
||||
this.setState({
|
||||
detailShow: false,
|
||||
detailRecord: null,
|
||||
});
|
||||
};
|
||||
|
||||
getEditorMaxWidth = () => {
|
||||
return Setting.isMobile() ? window.innerWidth - 80 : 520;
|
||||
};
|
||||
|
||||
jsonStrFormatter = (str) => {
|
||||
if (!str) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(str), null, 2);
|
||||
} catch (e) {
|
||||
return str;
|
||||
}
|
||||
};
|
||||
|
||||
getDetailField = (field) => {
|
||||
return this.state.detailRecord ? this.state.detailRecord[field] ?? "" : "";
|
||||
};
|
||||
|
||||
renderTable = () => {
|
||||
const columns = [
|
||||
{
|
||||
title: i18next.t("webhook:Webhook Name"),
|
||||
dataIndex: "webhookName",
|
||||
key: "webhookName",
|
||||
width: 220,
|
||||
render: (text) => this.getWebhookLink(text),
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Organization"),
|
||||
dataIndex: "organization",
|
||||
key: "organization",
|
||||
width: 160,
|
||||
render: (text) => text ? <Link to={`/organizations/${text}`}>{text}</Link> : "-",
|
||||
},
|
||||
{
|
||||
title: i18next.t("webhook:Status"),
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 140,
|
||||
filters: [
|
||||
{text: i18next.t("webhook:Pending"), value: "pending"},
|
||||
{text: i18next.t("webhook:Success"), value: "success"},
|
||||
{text: i18next.t("webhook:Failed"), value: "failed"},
|
||||
{text: i18next.t("webhook:Retrying"), value: "retrying"},
|
||||
],
|
||||
onFilter: (value, record) => record.status === value,
|
||||
render: (text) => this.getStatusTag(text),
|
||||
},
|
||||
{
|
||||
title: i18next.t("webhook:Attempt Count"),
|
||||
dataIndex: "attemptCount",
|
||||
key: "attemptCount",
|
||||
width: 140,
|
||||
sorter: (a, b) => (a.attemptCount || 0) - (b.attemptCount || 0),
|
||||
},
|
||||
{
|
||||
title: i18next.t("webhook:Next Retry Time"),
|
||||
dataIndex: "nextRetryTime",
|
||||
key: "nextRetryTime",
|
||||
width: 180,
|
||||
sorter: (a, b) => {
|
||||
const timeA = a.nextRetryTime ? new Date(a.nextRetryTime).getTime() : 0;
|
||||
const timeB = b.nextRetryTime ? new Date(b.nextRetryTime).getTime() : 0;
|
||||
return timeA - timeB;
|
||||
},
|
||||
render: (text) => text ? Setting.getFormattedDate(text) : "-",
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Action"),
|
||||
dataIndex: "action",
|
||||
key: "action",
|
||||
width: 180,
|
||||
fixed: Setting.isMobile() ? false : "right",
|
||||
render: (_, record) => {
|
||||
const eventId = `${record.owner}/${record.name}`;
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="link"
|
||||
style={{paddingLeft: 0}}
|
||||
onClick={() => this.openDetailDrawer(record)}
|
||||
>
|
||||
{i18next.t("general:View")}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={this.state.replayingId === eventId}
|
||||
onClick={() => this.replayWebhookEvent(record)}
|
||||
>
|
||||
{i18next.t("webhook:Replay")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
rowKey={(record) => `${record.owner}/${record.name}`}
|
||||
columns={columns}
|
||||
dataSource={this.state.data}
|
||||
loading={this.state.loading}
|
||||
pagination={{
|
||||
...this.state.pagination,
|
||||
showTotal: (total) => i18next.t("general:{total} in total").replace("{total}", total),
|
||||
}}
|
||||
scroll={{x: "max-content"}}
|
||||
size="middle"
|
||||
bordered
|
||||
title={() => i18next.t("webhook:Webhook Event Logs")}
|
||||
onChange={this.handleTableChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.state.isAuthorized) {
|
||||
return (
|
||||
<Result
|
||||
status="403"
|
||||
title={`403 ${i18next.t("general:Unauthorized")}`}
|
||||
subTitle={i18next.t("general:Sorry, you do not have permission to access this page or logged in status invalid.")}
|
||||
extra={<a href="/"><Button type="primary">{i18next.t("general:Back Home")}</Button></a>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{this.renderTable()}
|
||||
<Drawer
|
||||
title={i18next.t("webhook:Webhook Event Detail")}
|
||||
width={Setting.isMobile() ? "100%" : 720}
|
||||
placement="right"
|
||||
destroyOnClose
|
||||
onClose={this.closeDetailDrawer}
|
||||
open={this.state.detailShow}
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
size="small"
|
||||
column={1}
|
||||
layout={Setting.isMobile() ? "vertical" : "horizontal"}
|
||||
style={{padding: "12px", height: "100%", overflowY: "auto"}}
|
||||
>
|
||||
<Descriptions.Item label={i18next.t("webhook:Webhook Name")}>
|
||||
{this.getDetailField("webhookName") ? this.getWebhookLink(this.getDetailField("webhookName")) : "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("general:Organization")}>
|
||||
{this.getDetailField("organization") ? (
|
||||
<Link to={`/organizations/${this.getDetailField("organization")}`}>
|
||||
{this.getDetailField("organization")}
|
||||
</Link>
|
||||
) : "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("webhook:Status")}>
|
||||
{this.getStatusTag(this.getDetailField("status"))}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("webhook:Attempt Count")}>
|
||||
{this.getDetailField("attemptCount") || 0}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("webhook:Next Retry Time")}>
|
||||
{this.getDetailField("nextRetryTime") ? Setting.getFormattedDate(this.getDetailField("nextRetryTime")) : "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("webhook:Payload")}>
|
||||
<Editor
|
||||
value={this.jsonStrFormatter(this.getDetailField("payload"))}
|
||||
lang="json"
|
||||
fillHeight
|
||||
fillWidth
|
||||
maxWidth={this.getEditorMaxWidth()}
|
||||
dark
|
||||
readOnly
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("webhook:Last Error")}>
|
||||
<Editor
|
||||
value={this.getDetailField("lastError") || "-"}
|
||||
fillHeight
|
||||
fillWidth
|
||||
maxWidth={this.getEditorMaxWidth()}
|
||||
dark
|
||||
readOnly
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default WebhookEventListPage;
|
||||
44
web/src/backend/WebhookEventBackend.js
Normal file
44
web/src/backend/WebhookEventBackend.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as Setting from "../Setting";
|
||||
|
||||
export function getWebhookEvents(owner = "", organization = "", page = "", pageSize = "", webhookName = "", status = "") {
|
||||
const params = new URLSearchParams({
|
||||
owner,
|
||||
organization,
|
||||
pageSize,
|
||||
p: page,
|
||||
webhookName,
|
||||
status,
|
||||
});
|
||||
|
||||
return fetch(`${Setting.ServerUrl}/api/get-webhook-events?${params.toString()}`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Accept-Language": Setting.getAcceptLanguage(),
|
||||
},
|
||||
}).then(res => res.json());
|
||||
}
|
||||
|
||||
export function replayWebhookEvent(eventId) {
|
||||
return fetch(`${Setting.ServerUrl}/api/replay-webhook-event?id=${encodeURIComponent(eventId)}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Accept-Language": Setting.getAcceptLanguage(),
|
||||
},
|
||||
}).then(res => res.json());
|
||||
}
|
||||
@@ -88,6 +88,7 @@ export const NavItemTree = ({disabled, checkedKeys, defaultExpandedKeys, onCheck
|
||||
{title: i18next.t("general:System Info"), key: "/sysinfo"},
|
||||
{title: i18next.t("general:Syncers"), key: "/syncers"},
|
||||
{title: i18next.t("general:Webhooks"), key: "/webhooks"},
|
||||
{title: i18next.t("general:Webhook Events"), key: "/webhook-events"},
|
||||
{title: i18next.t("general:Swagger"), key: "/swagger"},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user