feat: add webhook delivery persistence, retry mechanism and replay UI (#5337)

This commit is contained in:
Paperlz
2026-03-30 22:53:56 +08:00
committed by GitHub
parent b690ee4ea3
commit 863d86d55f
13 changed files with 1119 additions and 13 deletions

View 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()
}

View File

@@ -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()
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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
View 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
View 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
}

View File

@@ -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")

View File

@@ -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"});

View File

@@ -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} />)} />

View 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;

View 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());
}

View File

@@ -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"},
],
},