From 5aec466c8ed659dd3212d035b3b4f52f8a1fd2d7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E8=98=AD?=
Date: Wed, 2 Sep 2026 14:49:20 +0800
Subject: [PATCH] feat: add support for custom webhook configuration (#13685)
---
agent/app/api/v2/alert.go | 40 +
agent/app/dto/alert.go | 45 +-
agent/app/dto/alert_webhook.go | 80 ++
agent/app/model/alert.go | 37 +-
agent/app/repo/alert.go | 226 +++-
agent/app/service/alert.go | 358 +++++-
agent/app/service/alert_helper.go | 32 +-
agent/app/service/alert_sender.go | 51 +-
agent/cmd/server/docs/x-log.json | 2 +-
agent/init/migration/migrate.go | 21 +-
agent/init/migration/migrations/init.go | 103 +-
agent/router/ro_alert.go | 1 +
agent/utils/alert/alert.go | 111 ++
agent/utils/alert/custom_webhook.go | 206 ++++
agent/utils/alert_config/secret.go | 312 +++++
agent/utils/alert_push/alert_push.go | 31 +-
agent/utils/alert_webhook/config.go | 1011 +++++++++++++++++
agent/utils/webhook_sender/render.go | 185 +++
agent/utils/webhook_sender/request.go | 767 +++++++++++++
agent/utils/webhook_sender/webhook_sender.go | 130 +++
agent/utils/xpack/alert_delivery.go | 57 +
agent/utils/xpack/helper/alert.go | 34 +
agent/utils/xpack/providers/alert.go | 17 +
core/app/model/alert.go | 10 +-
core/cmd/server/docs/x-log.json | 2 +-
core/init/router/proxy.go | 15 +
core/utils/alert_webhook/secret.go | 135 +++
.../req_helper/proxy_local/req_to_local.go | 25 +-
frontend/src/api/index.ts | 2 +-
frontend/src/api/interface/alert.ts | 25 +-
frontend/src/api/modules/alert.ts | 9 +
frontend/src/components/app-status/index.vue | 2 +-
frontend/src/lang/modules/en.ts | 54 +-
frontend/src/lang/modules/es-es.ts | 55 +-
frontend/src/lang/modules/fa.ts | 54 +-
frontend/src/lang/modules/ja.ts | 54 +-
frontend/src/lang/modules/ko.ts | 54 +-
frontend/src/lang/modules/lo.ts | 54 +-
frontend/src/lang/modules/ms.ts | 54 +-
frontend/src/lang/modules/pt-br.ts | 55 +-
frontend/src/lang/modules/ru.ts | 56 +-
frontend/src/lang/modules/tr.ts | 55 +-
frontend/src/lang/modules/zh-Hant.ts | 53 +-
frontend/src/lang/modules/zh.ts | 51 +-
.../views/cronjob/cronjob/operate/index.vue | 20 +-
.../src/views/setting/alert/dash/index.vue | 5 +-
.../views/setting/alert/dash/task/index.vue | 22 +-
.../src/views/setting/alert/log/index.vue | 8 +-
.../setting/drawer/custom-webhook-form.vue | 678 +++++++++++
.../alert/setting/drawer/custom-webhook.ts | 653 +++++++++++
.../setting/alert/setting/drawer/index.vue | 338 +++++-
.../alert/setting/drawer/secret-field.ts | 100 ++
.../src/views/setting/alert/setting/index.vue | 139 ++-
.../src/views/toolbox/clam/operate/index.vue | 20 +-
54 files changed, 6430 insertions(+), 284 deletions(-)
create mode 100644 agent/app/dto/alert_webhook.go
create mode 100644 agent/utils/alert/custom_webhook.go
create mode 100644 agent/utils/alert_config/secret.go
create mode 100644 agent/utils/alert_webhook/config.go
create mode 100644 agent/utils/webhook_sender/render.go
create mode 100644 agent/utils/webhook_sender/request.go
create mode 100644 agent/utils/webhook_sender/webhook_sender.go
create mode 100644 agent/utils/xpack/alert_delivery.go
create mode 100644 core/utils/alert_webhook/secret.go
create mode 100644 frontend/src/views/setting/alert/setting/drawer/custom-webhook-form.vue
create mode 100644 frontend/src/views/setting/alert/setting/drawer/custom-webhook.ts
create mode 100644 frontend/src/views/setting/alert/setting/drawer/secret-field.ts
diff --git a/agent/app/api/v2/alert.go b/agent/app/api/v2/alert.go
index 90c4480be..485f76b3f 100644
--- a/agent/app/api/v2/alert.go
+++ b/agent/app/api/v2/alert.go
@@ -2,11 +2,14 @@ package v2
import (
"errors"
+ "net/http"
"net/url"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
+ "github.com/1Panel-dev/1Panel/agent/app/repo"
+ "github.com/1Panel-dev/1Panel/agent/constant"
"github.com/gin-gonic/gin"
)
@@ -294,6 +297,34 @@ func (b *BaseApi) UpdateAlertConfig(c *gin.Context) {
return
}
if err := alertService.UpdateAlertConfig(req, loadAuditUser(c)); err != nil {
+ switch {
+ case errors.Is(err, repo.ErrAlertConfigRevisionConflict):
+ helper.ErrorWithBusinessCode(c, http.StatusConflict, "ALERT_CONFIG_REVISION_CONFLICT", "ErrInvalidParams", err)
+ case errors.Is(err, repo.ErrAlertConfigRevisionRequired):
+ helper.ErrorWithBusinessCode(c, http.StatusConflict, "ALERT_CONFIG_REVISION_REQUIRED", "ErrInvalidParams", err)
+ default:
+ helper.InternalServer(c, err)
+ }
+ return
+ }
+ helper.Success(c)
+}
+
+// @Tags Alert
+// @Summary Update alert config status
+// @Accept json
+// @Param request body dto.AlertConfigStatusUpdate true "request"
+// @Success 200
+// @Security ApiKeyAuth
+// @Security Timestamp
+// @Router /alert/config/status [post]
+// @x-panel-log {"bodyKeys":["id","status"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新告警配置状态 [id][status]","formatEN":"update alert config status [id][status]"}
+func (b *BaseApi) UpdateAlertConfigStatus(c *gin.Context) {
+ var req dto.AlertConfigStatusUpdate
+ if err := helper.CheckBindAndValidate(&req, c); err != nil {
+ return
+ }
+ if err := alertService.UpdateAlertConfigStatus(req, loadAuditUser(c)); err != nil {
helper.InternalServer(c, err)
return
}
@@ -346,6 +377,15 @@ func (b *BaseApi) TestAlertConfig(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
+ if req.Type == constant.Custom {
+ result, err := alertService.TestCustomAlertConfig(req)
+ if err != nil {
+ helper.InternalServer(c, err)
+ return
+ }
+ helper.SuccessWithData(c, result)
+ return
+ }
flag, err := alertService.TestAlertConfig(req)
if err != nil {
helper.InternalServer(c, err)
diff --git a/agent/app/dto/alert.go b/agent/app/dto/alert.go
index 30ea2ad12..84203c674 100644
--- a/agent/app/dto/alert.go
+++ b/agent/app/dto/alert.go
@@ -151,16 +151,25 @@ type AlertLog struct {
}
type AlertDetail struct {
- LicenseId string `json:"licenseId"`
- Type string `json:"type"`
- SubType string `json:"subType"`
- Title string `json:"title"`
- Method string `json:"method"`
- LicenseCode string `json:"licenseCode"`
- DeviceId string `json:"deviceId"`
- Project string `json:"project"`
- Params []Param `json:"params"`
- Phone string `json:"phone"`
+ LicenseId string `json:"licenseId"`
+ Type string `json:"type"`
+ SubType string `json:"subType"`
+ Title string `json:"title"`
+ Method string `json:"method"`
+ LicenseCode string `json:"licenseCode"`
+ DeviceId string `json:"deviceId"`
+ Project string `json:"project"`
+ Params []Param `json:"params"`
+ Phone string `json:"phone"`
+ Task *AlertTaskMetadata `json:"task,omitempty"`
+}
+
+type AlertTaskMetadata struct {
+ AlertID uint `json:"alertId"`
+ Type string `json:"type"`
+ Quota string `json:"quota"`
+ QuotaType string `json:"quotaType"`
+ Method string `json:"method"`
}
type AlertRule struct {
@@ -295,15 +304,19 @@ type OfflineQueryRequest struct {
}
type AlertConfigUpdate struct {
- ID uint `json:"id"`
- Type string `json:"type"`
- Title string `json:"title"`
- Status string `json:"status"`
- Config string `json:"config"`
- DisplayName string `json:"displayName"`
+ ID uint `json:"id"`
+ Type string `json:"type"`
+ Title string `json:"title"`
+ Status string `json:"status"`
+ Config string `json:"config"`
+ DisplayName string `json:"displayName"`
+ Revision *time.Time `json:"revision"`
}
type AlertConfigTest struct {
+ ID uint `json:"id"`
+ Type string `json:"type"`
+ Config string `json:"config"`
Host string `json:"host"`
Port int `json:"port"`
Sender string `json:"sender"`
diff --git a/agent/app/dto/alert_webhook.go b/agent/app/dto/alert_webhook.go
new file mode 100644
index 000000000..39fddceca
--- /dev/null
+++ b/agent/app/dto/alert_webhook.go
@@ -0,0 +1,80 @@
+package dto
+
+const AlertCustomWebhookSchemaVersion = 1
+
+type AlertConfigStatusUpdate struct {
+ ID uint `json:"id" validate:"required"`
+ Status string `json:"status" validate:"required,oneof=Enable Disable"`
+}
+
+type AlertCustomWebhookSecretMutation struct {
+ Action string `json:"action,omitempty"`
+ Value string `json:"value,omitempty"`
+}
+
+type AlertCustomWebhookURL struct {
+ AlertCustomWebhookSecretMutation
+ Configured bool `json:"configured"`
+ Masked string `json:"masked,omitempty"`
+}
+
+type AlertCustomWebhookBody struct {
+ Type string `json:"type"`
+ Template string `json:"template,omitempty"`
+ Fields []AlertCustomWebhookFormField `json:"fields,omitempty"`
+}
+
+type AlertCustomWebhookFormField struct {
+ Key string `json:"key"`
+ Value string `json:"value"`
+}
+
+type AlertCustomWebhookHeader struct {
+ UID string `json:"uid"`
+ Key string `json:"key"`
+ Secret bool `json:"secret"`
+ Action string `json:"action,omitempty"`
+ Value string `json:"value,omitempty"`
+ Configured bool `json:"configured,omitempty"`
+ Masked string `json:"masked,omitempty"`
+}
+
+type AlertCustomWebhookConfig struct {
+ SchemaVersion int `json:"schemaVersion"`
+ State string `json:"state,omitempty"`
+ DisplayName string `json:"displayName"`
+ Preset string `json:"preset"`
+ Method string `json:"method"`
+ URL AlertCustomWebhookURL `json:"url"`
+ Body AlertCustomWebhookBody `json:"body"`
+ Headers []AlertCustomWebhookHeader `json:"headers"`
+}
+
+type AlertCustomWebhookSecretConfig struct {
+ SchemaVersion int `json:"schemaVersion"`
+ URL string `json:"url"`
+ Headers map[string]string `json:"headers,omitempty"`
+}
+
+type AlertCustomWebhookResolvedConfig struct {
+ SchemaVersion int
+ DisplayName string
+ Preset string
+ Method string
+ URL string
+ Body AlertCustomWebhookBody
+ Headers []AlertCustomWebhookResolvedHeader
+}
+
+type AlertCustomWebhookResolvedHeader struct {
+ Key string
+ Value string
+}
+
+type AlertConfigTestResult struct {
+ Success bool `json:"success"`
+ StatusCode int `json:"statusCode,omitempty"`
+ Duration int64 `json:"duration,omitempty"` // milliseconds
+ Message string `json:"message,omitempty"`
+ Response string `json:"response,omitempty"`
+}
diff --git a/agent/app/model/alert.go b/agent/app/model/alert.go
index 560965a76..34c3fc721 100644
--- a/agent/app/model/alert.go
+++ b/agent/app/model/alert.go
@@ -1,5 +1,12 @@
package model
+import (
+ "strings"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
type Alert struct {
BaseModel
@@ -18,10 +25,11 @@ type Alert struct {
type AlertTask struct {
BaseModel
- Type string `gorm:"type:varchar(64);not null" json:"type"`
- Quota string `gorm:"type:varchar(64)" json:"quota"`
- QuotaType string `gorm:"type:varchar(64)" json:"quotaType"`
- Method string `gorm:"type:varchar(128);not null;default:'sms'" json:"method"`
+ Type string `gorm:"type:varchar(64);not null" json:"type"`
+ Quota string `gorm:"type:varchar(64)" json:"quota"`
+ QuotaType string `gorm:"type:varchar(64)" json:"quotaType"`
+ Method string `gorm:"type:varchar(128);not null;default:'sms'" json:"method"`
+ DeliveryLogID *uint `gorm:"uniqueIndex" json:"-"`
}
type AlertLog struct {
@@ -41,12 +49,21 @@ type AlertLog struct {
type AlertConfig struct {
BaseModel
- Type string `gorm:"type:varchar(64);not null" json:"type"`
- Title string `gorm:"type:varchar(64);not null" json:"title"`
- Status string `gorm:"type:varchar(64);not null" json:"status"`
- Config string `gorm:"type:varchar(256);not null" json:"config"`
- CreateUser string `gorm:"type:varchar(256)" json:"createUser"`
- UpdateUser string `gorm:"type:varchar(256)" json:"updateUser"`
+ UID string `gorm:"type:varchar(64);not null;uniqueIndex" json:"uid"`
+ Type string `gorm:"type:varchar(64);not null" json:"type"`
+ Title string `gorm:"type:varchar(64);not null" json:"title"`
+ Status string `gorm:"type:varchar(64);not null" json:"status"`
+ Config string `gorm:"type:text;not null" json:"config"`
+ SecretConfig string `gorm:"type:text;not null;default:''" json:"-"`
+ CreateUser string `gorm:"type:varchar(256)" json:"createUser"`
+ UpdateUser string `gorm:"type:varchar(256)" json:"updateUser"`
+}
+
+func (a *AlertConfig) BeforeCreate(_ *gorm.DB) error {
+ if strings.TrimSpace(a.UID) == "" {
+ a.UID = uuid.NewString()
+ }
+ return nil
}
type LoginLog struct {
diff --git a/agent/app/repo/alert.go b/agent/app/repo/alert.go
index 015a9b0eb..a7d24c236 100644
--- a/agent/app/repo/alert.go
+++ b/agent/app/repo/alert.go
@@ -1,20 +1,30 @@
package repo
import (
+ "encoding/base64"
"encoding/json"
+ "errors"
+ "fmt"
+ "strconv"
"strings"
+ "time"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
+ "github.com/google/uuid"
"google.golang.org/genproto/googleapis/type/date"
"gorm.io/gorm"
- "strconv"
- "time"
+ "gorm.io/gorm/clause"
)
type AlertRepo struct{}
+var (
+ ErrAlertConfigRevisionConflict = errors.New("alert config revision conflict")
+ ErrAlertConfigRevisionRequired = errors.New("alert config revision is required")
+)
+
type IAlertRepo interface {
WithByType(alertType string) DBOption
WithByStatusIn(status []string) DBOption
@@ -24,6 +34,7 @@ type IAlertRepo interface {
WithByCreateAt(date *date.Date) DBOption
WithByLicenseId(licenseId string) DBOption
WithByRecordId(recordId uint) DBOption
+ WithByDeliveryLogID(logID uint) DBOption
WithByAlertMethodContainsConfigID(id uint) DBOption
WithByMethodConfigIDs(ids []uint) DBOption
@@ -45,6 +56,8 @@ type IAlertRepo interface {
CleanAlertLogs() error
CreateAlertTask(alertTaskBase *model.AlertTask) error
+ CreatePendingAlertTask(logID, alertID uint, alertTask *model.AlertTask) (bool, error)
+ FinalizePendingAlertTask(logID uint, succeeded bool, message string, fallback *model.AlertTask) (bool, error)
DeleteAlertTask(opts ...DBOption) error
GetAlertTask(opts ...DBOption) (model.AlertTask, error)
LoadTaskCount(alertType string, project string, method string) (uint, uint, error)
@@ -55,6 +68,7 @@ type IAlertRepo interface {
GetConfigById(id uint) (model.AlertConfig, error)
AlertConfigList(opts ...DBOption) ([]model.AlertConfig, error)
UpdateAlertConfig(maps map[string]interface{}, opts ...DBOption) error
+ UpdateAlertConfigWithRevision(maps map[string]interface{}, revision *time.Time, opts ...DBOption) error
CreateAlertConfig(config *model.AlertConfig) error
DeleteAlertConfig(opts ...DBOption) error
@@ -223,13 +237,78 @@ func (a *AlertRepo) DeleteLog(opts ...DBOption) error {
}
func (a *AlertRepo) CleanAlertLogs() error {
- return global.AlertDB.Where("1 = 1").Delete(&model.AlertLog{}).Error
+ return global.AlertDB.Where("status <> ?", constant.AlertPushing).Delete(&model.AlertLog{}).Error
}
func (a *AlertRepo) CreateAlertTask(alertTaskBase *model.AlertTask) error {
return global.AlertDB.Model(&model.AlertTask{}).Create(&alertTaskBase).Error
}
+func (a *AlertRepo) CreatePendingAlertTask(logID, alertID uint, alertTask *model.AlertTask) (bool, error) {
+ if alertTask == nil {
+ return false, fmt.Errorf("pending alert task is required")
+ }
+ created := false
+ err := global.AlertDB.Transaction(func(tx *gorm.DB) error {
+ var log model.AlertLog
+ if err := tx.Where("id = ? AND status = ?", logID, constant.AlertPushing).First(&log).Error; err != nil {
+ return err
+ }
+ if log.AlertId != alertID || log.Type != alertTask.Type || log.Method != alertTask.Method {
+ return fmt.Errorf("pending alert task does not match delivery log %d", logID)
+ }
+ alertTask.DeliveryLogID = &logID
+ result := tx.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "delivery_log_id"}},
+ DoNothing: true,
+ }).Create(alertTask)
+ if result.Error != nil {
+ return result.Error
+ }
+ created = result.RowsAffected > 0
+ return nil
+ })
+ return created, err
+}
+
+func (a *AlertRepo) FinalizePendingAlertTask(logID uint, succeeded bool, message string, fallback *model.AlertTask) (bool, error) {
+ finalized := false
+ err := global.AlertDB.Transaction(func(tx *gorm.DB) error {
+ status := constant.AlertError
+ if succeeded {
+ status = constant.AlertSuccess
+ message = ""
+ }
+ result := tx.Model(&model.AlertLog{}).
+ Where("id = ? AND status = ?", logID, constant.AlertPushing).
+ Updates(map[string]interface{}{"status": status, "message": message})
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 0 {
+ return nil
+ }
+ finalized = true
+ if !succeeded {
+ return tx.Where("delivery_log_id = ?", logID).Delete(&model.AlertTask{}).Error
+ }
+
+ var count int64
+ if err := tx.Model(&model.AlertTask{}).Where("delivery_log_id = ?", logID).Count(&count).Error; err != nil {
+ return err
+ }
+ if count > 0 {
+ return nil
+ }
+ if fallback == nil {
+ return fmt.Errorf("pending alert task metadata is unavailable for delivery log %d", logID)
+ }
+ fallback.DeliveryLogID = &logID
+ return tx.Create(fallback).Error
+ })
+ return finalized, err
+}
+
func (a *AlertRepo) DeleteAlertTask(opts ...DBOption) error {
db, _ := getAlertDB(opts...)
return db.Delete(&model.AlertTask{}).Error
@@ -310,7 +389,23 @@ func (a *AlertRepo) UpdateAlertConfig(maps map[string]interface{}, opts ...DBOpt
return db.Model(&model.AlertConfig{}).Updates(maps).Error
}
+func (a *AlertRepo) UpdateAlertConfigWithRevision(maps map[string]interface{}, revision *time.Time, opts ...DBOption) error {
+ if revision == nil {
+ return a.UpdateAlertConfig(maps, opts...)
+ }
+ db, _ := getAlertDB(opts...)
+ result := db.Model(&model.AlertConfig{}).Where("updated_at = ?", *revision).Updates(maps)
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 0 {
+ return ErrAlertConfigRevisionConflict
+ }
+ return nil
+}
+
func (a *AlertRepo) CreateAlertConfig(config *model.AlertConfig) error {
+ ensureAlertConfigUID(config)
return global.AlertDB.Model(&model.AlertConfig{}).Create(config).Error
}
@@ -338,6 +433,12 @@ func (a *AlertRepo) WithByTypeNotIn(types []string) DBOption {
}
}
+func (a *AlertRepo) WithByDeliveryLogID(logID uint) DBOption {
+ return func(g *gorm.DB) *gorm.DB {
+ return g.Where("delivery_log_id = ?", logID)
+ }
+}
+
func (a *AlertRepo) PageAlertConfig(page, size int, opts ...DBOption) (int64, []model.AlertConfig, error) {
var configs []model.AlertConfig
db := global.AlertDB.Model(&model.AlertConfig{})
@@ -378,26 +479,44 @@ func (a *AlertRepo) SyncAll(data []model.AlertConfig) error {
return err
}
- oldConfigMap := make(map[string]uint)
+ oldConfigMap := make(map[string]model.AlertConfig)
+ oldConfigByUID := make(map[string]model.AlertConfig)
oldConfigByType := make(map[string][]model.AlertConfig)
oldConfigByKey := make(map[string][]model.AlertConfig)
consumedConfigIDs := make(map[uint]struct{})
for _, item := range oldConfigs {
+ if strings.TrimSpace(item.UID) != "" {
+ oldConfigByUID[item.UID] = item
+ }
if singletonTypes[item.Type] {
- oldConfigMap[item.Type] = item.ID
+ oldConfigMap[item.Type] = item
continue
}
oldConfigByType[item.Type] = append(oldConfigByType[item.Type], item)
oldConfigByKey[alertConfigSyncKey(item)] = append(oldConfigByKey[alertConfigSyncKey(item)], item)
}
for _, item := range data {
+ if uid := strings.TrimSpace(item.UID); uid != "" {
+ if matched, ok := oldConfigByUID[uid]; ok && matched.Type != item.Type {
+ tx.Rollback()
+ return fmt.Errorf("alert config UID %q belongs to type %q, not %q", uid, matched.Type, item.Type)
+ }
+ }
if singletonTypes[item.Type] {
- if val, ok := oldConfigMap[item.Type]; ok {
- item.ID = val
+ if matched, ok := oldConfigMap[item.Type]; ok {
+ if err := inheritAlertConfigSyncState(&item, matched); err != nil {
+ tx.Rollback()
+ return err
+ }
delete(oldConfigMap, item.Type)
consumedConfigIDs[item.ID] = struct{}{}
} else {
item.ID = 0
+ ensureAlertConfigUID(&item)
+ if err := validateAlertConfigSyncSecret(&item); err != nil {
+ tx.Rollback()
+ return err
+ }
}
if item.ID == 0 {
if err := tx.Create(&item).Error; err != nil {
@@ -411,9 +530,31 @@ func (a *AlertRepo) SyncAll(data []model.AlertConfig) error {
continue
}
+ if strings.TrimSpace(item.UID) != "" {
+ if matched, ok := oldConfigByUID[item.UID]; ok {
+ delete(oldConfigByUID, item.UID)
+ if err := inheritAlertConfigSyncState(&item, matched); err != nil {
+ tx.Rollback()
+ return err
+ }
+ consumedConfigIDs[item.ID] = struct{}{}
+ if err := tx.Save(&item).Error; err != nil {
+ tx.Rollback()
+ return err
+ }
+ deleteAlertConfigByID(oldConfigByType, matched.ID)
+ deleteAlertConfigByID(oldConfigByKey, matched.ID)
+ continue
+ }
+ }
+
key := alertConfigSyncKey(item)
if matched, ok := popAlertConfigByKey(oldConfigByKey, key); ok {
- item.ID = matched.ID
+ delete(oldConfigByUID, matched.UID)
+ if err := inheritAlertConfigSyncState(&item, matched); err != nil {
+ tx.Rollback()
+ return err
+ }
consumedConfigIDs[item.ID] = struct{}{}
if err := tx.Save(&item).Error; err != nil {
tx.Rollback()
@@ -424,7 +565,12 @@ func (a *AlertRepo) SyncAll(data []model.AlertConfig) error {
}
if matched, ok := popUnusedAlertConfigByType(oldConfigByType, usedConfigIDs, item.Type); ok {
- item.ID = matched.ID
+ delete(oldConfigByUID, matched.UID)
+ deleteAlertConfigByID(oldConfigByKey, matched.ID)
+ if err := inheritAlertConfigSyncState(&item, matched); err != nil {
+ tx.Rollback()
+ return err
+ }
consumedConfigIDs[item.ID] = struct{}{}
if err := tx.Save(&item).Error; err != nil {
tx.Rollback()
@@ -434,6 +580,11 @@ func (a *AlertRepo) SyncAll(data []model.AlertConfig) error {
}
item.ID = 0
+ ensureAlertConfigUID(&item)
+ if err := validateAlertConfigSyncSecret(&item); err != nil {
+ tx.Rollback()
+ return err
+ }
if err := tx.Create(&item).Error; err != nil {
tx.Rollback()
return err
@@ -458,6 +609,63 @@ func (a *AlertRepo) SyncAll(data []model.AlertConfig) error {
return nil
}
+func ensureAlertConfigUID(config *model.AlertConfig) {
+ if config != nil && strings.TrimSpace(config.UID) == "" {
+ config.UID = uuid.NewString()
+ }
+}
+
+func inheritAlertConfigSyncState(incoming *model.AlertConfig, existing model.AlertConfig) error {
+ if incoming.Type != existing.Type {
+ return fmt.Errorf("alert config UID %q belongs to type %q, not %q", incoming.UID, existing.Type, incoming.Type)
+ }
+ preserveExistingCustom := incoming.Type == constant.Custom &&
+ existing.Status == constant.AlertDisable &&
+ incoming.Title == existing.Title &&
+ incoming.Status == existing.Status &&
+ incoming.Config == existing.Config &&
+ (incoming.SecretConfig == "" || incoming.SecretConfig == existing.SecretConfig)
+ incoming.ID = existing.ID
+ if strings.TrimSpace(incoming.UID) == "" {
+ incoming.UID = existing.UID
+ }
+ if incoming.Type == constant.Custom && incoming.SecretConfig == "" {
+ incoming.SecretConfig = existing.SecretConfig
+ }
+ if preserveExistingCustom {
+ return nil
+ }
+ return validateAlertConfigSyncSecret(incoming)
+}
+
+func validateAlertConfigSyncSecret(incoming *model.AlertConfig) error {
+ if incoming.Type != constant.Custom {
+ incoming.SecretConfig = ""
+ return nil
+ }
+ if strings.TrimSpace(incoming.SecretConfig) == "" {
+ return fmt.Errorf("custom webhook sync secret is missing")
+ }
+ var version struct {
+ SchemaVersion int `json:"schemaVersion"`
+ }
+ if err := json.Unmarshal([]byte(incoming.Config), &version); err != nil || version.SchemaVersion != 1 {
+ return fmt.Errorf("custom webhook sync config must use schemaVersion 1")
+ }
+ secret := incoming.SecretConfig
+ for _, prefix := range []string{"core:v1:", "agent:v1:"} {
+ if !strings.HasPrefix(secret, prefix) {
+ continue
+ }
+ ciphertext, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, prefix))
+ if err != nil || len(ciphertext) < 32 || len(ciphertext)%16 != 0 {
+ return fmt.Errorf("custom webhook sync secret envelope is invalid")
+ }
+ return nil
+ }
+ return fmt.Errorf("custom webhook sync secret must use a versioned envelope")
+}
+
func loadUsedAlertConfigIDs(tx *gorm.DB) (map[uint]struct{}, error) {
var alerts []model.Alert
if err := tx.Select("method").Find(&alerts).Error; err != nil {
diff --git a/agent/app/service/alert.go b/agent/app/service/alert.go
index 5b7f5e7d1..c981c88f9 100644
--- a/agent/app/service/alert.go
+++ b/agent/app/service/alert.go
@@ -17,10 +17,13 @@ import (
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
+ alertconfig "github.com/1Panel-dev/1Panel/agent/utils/alert_config"
+ alertwebhook "github.com/1Panel-dev/1Panel/agent/utils/alert_webhook"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/copier"
"github.com/1Panel-dev/1Panel/agent/utils/email"
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
+ "github.com/1Panel-dev/1Panel/agent/utils/xpack/providers"
"github.com/shirou/gopsutil/v4/disk"
)
@@ -34,6 +37,28 @@ var communityAlertMethodTypeNames = map[string]string{
constant.SMS: "SMS",
}
+var legacyAlertMethodTypeMap = map[string]string{
+ "mail": constant.Email,
+ constant.Email: constant.Email,
+ constant.SMS: constant.SMS,
+ constant.Bark: constant.Bark,
+ constant.WeChat: constant.WeCom,
+ constant.WeCom: constant.WeCom,
+ constant.DingTalk: constant.DingTalk,
+ constant.FeiShu: constant.FeiShu,
+ constant.Custom: constant.Custom,
+}
+
+var supportedAlertMethodTypes = map[string]struct{}{
+ constant.Email: {},
+ constant.SMS: {},
+ constant.Bark: {},
+ constant.WeCom: {},
+ constant.DingTalk: {},
+ constant.FeiShu: {},
+ constant.Custom: {},
+}
+
type IAlertService interface {
PageAlert(req dto.AlertSearch) (int64, []dto.AlertDTO, error)
GetAlerts() ([]dto.AlertDTO, error)
@@ -53,8 +78,10 @@ type IAlertService interface {
GetAlertConfig(req dto.AlertConfigQuery) ([]model.AlertConfig, error)
PageAlertConfig(req dto.AlertConfigPageReq) (int64, []model.AlertConfig, error)
UpdateAlertConfig(req dto.AlertConfigUpdate, operator string) error
+ UpdateAlertConfigStatus(req dto.AlertConfigStatusUpdate, operator string) error
DeleteAlertConfig(id uint) error
TestAlertConfig(req dto.AlertConfigTest) (bool, error)
+ TestCustomAlertConfig(req dto.AlertConfigTest) (dto.AlertConfigTestResult, error)
}
func NewIAlertService() IAlertService {
@@ -180,9 +207,15 @@ func (a AlertService) CreateAlert(create dto.AlertCreate, operator string) error
}
func (a AlertService) UpdateAlert(req dto.AlertUpdate, operator string) error {
- if err := a.validateCommunityAlertMethod(req.Method); err != nil {
+ methodTypes, err := a.validateAlertMethodReferences(req.Method)
+ if err != nil {
return err
}
+ if req.Status != constant.AlertDisable {
+ if err := a.validateAlertMethodEntitlement(methodTypes); err != nil {
+ return err
+ }
+ }
upMap := make(map[string]interface{})
upMap["id"] = req.ID
@@ -240,7 +273,16 @@ func (a AlertService) UpdateStatus(id uint, status string) error {
if alertInfo.ID == 0 {
return buserr.New("ErrRecordNotFound")
}
- err := alertRepo.Update(map[string]interface{}{"status": status}, repo.WithByID(alertInfo.ID))
+ methodTypes, err := a.validateAlertMethodReferences(alertInfo.Method)
+ if err != nil {
+ return err
+ }
+ if status == constant.AlertEnable {
+ if err := a.validateAlertMethodEntitlement(methodTypes); err != nil {
+ return err
+ }
+ }
+ err = alertRepo.Update(map[string]interface{}{"status": status}, repo.WithByID(alertInfo.ID))
if err != nil {
return err
}
@@ -412,6 +454,7 @@ func (a AlertService) parseAlertLog(item model.AlertLog) (dto.AlertLogDTO, error
if err := unmarshalAlertInfo(item.AlertDetail, &alertDetail); err != nil {
return dto.AlertLogDTO{}, err
}
+ alertDetail.Task = nil
if err := unmarshalAlertInfo(item.AlertRule, &alertRule); err != nil {
return dto.AlertLogDTO{}, err
}
@@ -494,7 +537,13 @@ func (a AlertService) GetAlertConfig(req dto.AlertConfigQuery) ([]model.AlertCon
}
opts = append(opts, repo.WithByStatus(constant.AlertEnable))
configs, err := alertRepo.AlertConfigList(opts...)
- return configs, err
+ if err != nil {
+ return nil, err
+ }
+ if err := exposeCustomAlertConfigSecrets(configs); err != nil {
+ return nil, err
+ }
+ return configs, nil
}
func (a AlertService) PageAlertConfig(req dto.AlertConfigPageReq) (int64, []model.AlertConfig, error) {
@@ -505,13 +554,49 @@ func (a AlertService) PageAlertConfig(req dto.AlertConfigPageReq) (int64, []mode
if len(req.ExcludeTypes) > 0 {
opts = append(opts, alertRepo.WithByTypeNotIn(req.ExcludeTypes))
}
- return alertRepo.PageAlertConfig(req.Page, req.PageSize, opts...)
+ total, configs, err := alertRepo.PageAlertConfig(req.Page, req.PageSize, opts...)
+ if err != nil {
+ return 0, nil, err
+ }
+ if err := exposeCustomAlertConfigSecrets(configs); err != nil {
+ return 0, nil, err
+ }
+ return total, configs, nil
}
func (a AlertService) UpdateAlertConfig(req dto.AlertConfigUpdate, operator string) error {
+ if req.Type == constant.Custom {
+ if req.ID != 0 && req.Revision == nil {
+ return repo.ErrAlertConfigRevisionRequired
+ }
+ return a.updateCustomAlertConfig(req, operator)
+ }
+ usesMutation, err := alertconfig.UsesMutation(req.Type, req.Config)
+ if err != nil {
+ return err
+ }
+ if req.ID != 0 && usesMutation && req.Revision == nil {
+ return repo.ErrAlertConfigRevisionRequired
+ }
+ var existing *model.AlertConfig
+ if req.ID != 0 {
+ stored, err := alertRepo.GetConfigById(req.ID)
+ if err != nil {
+ return err
+ }
+ if stored.Type != req.Type {
+ return fmt.Errorf("alert config %d has type %s, not %s", req.ID, stored.Type, req.Type)
+ }
+ existing = &stored
+ }
if err := a.validateCommunityAlertConfigType(req.Type); err != nil {
return err
}
+ prepared, err := alertconfig.Prepare(req.Type, req.Config, req.Status, existing)
+ if err != nil {
+ return err
+ }
+ req.Config = prepared
if err := a.checkAlertConfigDisplayNameUnique(req); err != nil {
return err
}
@@ -526,7 +611,7 @@ func (a AlertService) UpdateAlertConfig(req dto.AlertConfigUpdate, operator stri
upMap["status"] = req.Status
upMap["config"] = req.Config
upMap["update_user"] = operator
- if err := alertRepo.UpdateAlertConfig(upMap, repo.WithByID(req.ID)); err != nil {
+ if err := alertRepo.UpdateAlertConfigWithRevision(upMap, req.Revision, repo.WithByID(req.ID)); err != nil {
return err
}
} else {
@@ -544,6 +629,99 @@ func (a AlertService) UpdateAlertConfig(req dto.AlertConfigUpdate, operator stri
return nil
}
+func (a AlertService) updateCustomAlertConfig(req dto.AlertConfigUpdate, operator string) error {
+ if err := validateAlertConfigStatus(req.Status); err != nil {
+ return err
+ }
+
+ var existing *model.AlertConfig
+ if req.ID != 0 {
+ config, err := alertRepo.GetConfigById(req.ID)
+ if err != nil {
+ return err
+ }
+ if config.Type != constant.Custom {
+ return fmt.Errorf("alert config %d is not a custom webhook", req.ID)
+ }
+ existing = &config
+ }
+ prepared, err := alertwebhook.Prepare(req.Config, req.Status, existing)
+ if err != nil {
+ return err
+ }
+ validatedReq := req
+ validatedReq.Config = prepared.Config
+ if err := a.checkAlertConfigDisplayNameUnique(validatedReq); err != nil {
+ return err
+ }
+
+ if existing != nil {
+ return alertRepo.UpdateAlertConfigWithRevision(map[string]interface{}{
+ "type": constant.Custom,
+ "title": req.Title,
+ "status": req.Status,
+ "config": prepared.Config,
+ "secret_config": prepared.SecretConfig,
+ "update_user": operator,
+ }, req.Revision, repo.WithByID(req.ID))
+ }
+
+ return alertRepo.CreateAlertConfig(&model.AlertConfig{
+ Type: constant.Custom,
+ Title: req.Title,
+ Status: req.Status,
+ Config: prepared.Config,
+ SecretConfig: prepared.SecretConfig,
+ CreateUser: operator,
+ UpdateUser: operator,
+ })
+}
+
+func (a AlertService) UpdateAlertConfigStatus(req dto.AlertConfigStatusUpdate, operator string) error {
+ if err := validateAlertConfigStatus(req.Status); err != nil {
+ return err
+ }
+ config, err := alertRepo.GetConfigById(req.ID)
+ if err != nil {
+ return err
+ }
+ if req.Status == constant.AlertEnable {
+ if err := a.validateCommunityAlertConfigType(config.Type); err != nil {
+ return err
+ }
+ if config.Type == constant.Custom {
+ if _, err := alertwebhook.Resolve(config); err != nil {
+ return err
+ }
+ }
+ }
+ return alertRepo.UpdateAlertConfig(map[string]interface{}{
+ "status": req.Status,
+ "update_user": operator,
+ }, repo.WithByID(req.ID))
+}
+
+func validateAlertConfigStatus(status string) error {
+ if status != constant.AlertEnable && status != constant.AlertDisable {
+ return fmt.Errorf("alert config status must be Enable or Disable")
+ }
+ return nil
+}
+
+func exposeCustomAlertConfigSecrets(configs []model.AlertConfig) error {
+ for index := range configs {
+ if configs[index].Type != constant.Custom {
+ continue
+ }
+ view, err := alertwebhook.PlainView(configs[index])
+ if err != nil {
+ return fmt.Errorf("build editable custom alert config %d: %w", configs[index].ID, err)
+ }
+ configs[index].Config = view
+ }
+ return nil
+}
+
func (a AlertService) checkAlertConfigSMSPhoneUnique(req dto.AlertConfigUpdate) error {
if req.Type != constant.SMSConfig {
return nil
@@ -568,6 +746,9 @@ func (a AlertService) checkAlertConfigSMSPhoneUnique(req dto.AlertConfigUpdate)
}
func (a AlertService) checkAlertConfigDisplayNameUnique(req dto.AlertConfigUpdate) error {
+ if req.Type != constant.Custom && (global.CONF.Base.IsEnterprise || global.CONF.Base.Edition == "cn") {
+ return nil
+ }
displayName := alertConfigDisplayName(req.Type, req.Config)
if displayName == "" {
return nil
@@ -591,37 +772,67 @@ func (a AlertService) checkAlertConfigDisplayNameUnique(req dto.AlertConfigUpdat
}
func (a AlertService) validateCommunityAlertMethod(method string) error {
- if global.CONF.Base.IsEnterprise || global.CONF.Base.Edition == "cn" {
- return nil
- }
- if strings.TrimSpace(method) == "" {
- return nil
+ methodTypes, err := a.validateAlertMethodReferences(method)
+ if err != nil {
+ return err
}
+ return a.validateAlertMethodEntitlement(methodTypes)
+}
+func (a AlertService) validateAlertMethodReferences(method string) ([]string, error) {
+ if strings.TrimSpace(method) == "" {
+ return nil, buserr.WithErr("ErrAlertMethodNotSupported", nil)
+ }
+ methodTypes := make([]string, 0)
for _, item := range strings.Split(method, ",") {
item = strings.TrimSpace(item)
if item == "" {
continue
}
+ configType := ""
if configID, err := strconv.ParseUint(item, 10, 64); err == nil {
config, err := alertRepo.GetConfigById(uint(configID))
if err != nil {
- return err
+ return nil, err
}
- if _, ok := communityAlertMethodTypeNames[config.Type]; ok {
- return buserr.WithErr("ErrAlertMethodNotSupported", nil)
+ configType = config.Type
+ } else {
+ var ok bool
+ configType, ok = legacyAlertMethodTypeMap[item]
+ if !ok {
+ return nil, buserr.WithErr("ErrAlertMethodNotSupported", nil)
}
+ }
+ if _, ok := supportedAlertMethodTypes[configType]; !ok {
+ return nil, buserr.WithErr("ErrAlertMethodNotSupported", nil)
+ }
+ methodTypes = append(methodTypes, configType)
+ }
+ if len(methodTypes) == 0 {
+ return nil, buserr.WithErr("ErrAlertMethodNotSupported", nil)
+ }
+ return methodTypes, nil
+}
+
+func (a AlertService) validateAlertMethodEntitlement(methodTypes []string) error {
+ for _, configType := range methodTypes {
+ if configType == constant.Custom {
continue
}
- if _, ok := communityAlertMethodTypeNames[item]; ok {
+ if global.CONF.Base.IsEnterprise || global.CONF.Base.Edition == "cn" {
+ continue
+ }
+ if _, ok := communityAlertMethodTypeNames[configType]; ok {
return buserr.WithErr("ErrAlertMethodNotSupported", nil)
}
}
-
return nil
}
func (a AlertService) validateCommunityAlertConfigType(configType string) error {
+ if configType == constant.Custom {
+ return nil
+ }
if global.CONF.Base.IsEnterprise || global.CONF.Base.Edition == "cn" {
return nil
}
@@ -633,7 +844,7 @@ func (a AlertService) validateCommunityAlertConfigType(configType string) error
func alertConfigDisplayName(configType, configData string) string {
switch configType {
- case constant.Email, constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Bark, constant.SMS:
+ case constant.Email, constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Bark, constant.SMS, constant.Custom:
var cfg struct {
DisplayName string `json:"displayName"`
}
@@ -672,20 +883,24 @@ func (a AlertService) DeleteAlertConfig(id uint) error {
}
func (a AlertService) TestAlertConfig(req dto.AlertConfigTest) (bool, error) {
- username := req.UserName
- if username == "" {
- username = req.Sender
+ emailConfig, err := resolveEmailTestConfig(req)
+ if err != nil {
+ return false, err
}
- encodedDisplayName := mime.BEncoding.Encode("UTF-8", req.DisplayName)
+ username := emailConfig.UserName
+ if username == "" {
+ username = emailConfig.Sender
+ }
+ encodedDisplayName := mime.BEncoding.Encode("UTF-8", emailConfig.DisplayName)
cfg := email.SMTPConfig{
- Host: req.Host,
- Port: req.Port,
- Sender: req.Sender,
+ Host: emailConfig.Host,
+ Port: emailConfig.Port,
+ Sender: emailConfig.Sender,
Username: username,
- Password: req.Password,
- From: fmt.Sprintf(`"%s" <%s>`, encodedDisplayName, req.Sender),
- Encryption: req.Encryption,
- Recipient: req.Recipient,
+ Password: emailConfig.Password,
+ From: fmt.Sprintf(`"%s" <%s>`, encodedDisplayName, emailConfig.Sender),
+ Encryption: emailConfig.Encryption,
+ Recipient: emailConfig.Recipient,
}
msg := email.EmailMessage{
@@ -700,9 +915,94 @@ func (a AlertService) TestAlertConfig(req dto.AlertConfigTest) (bool, error) {
return true, nil
}
+func resolveEmailTestConfig(req dto.AlertConfigTest) (dto.AlertEmailConfig, error) {
+ emailConfig := dto.AlertEmailConfig{
+ Host: req.Host,
+ Port: req.Port,
+ Sender: req.Sender,
+ UserName: req.UserName,
+ Password: req.Password,
+ DisplayName: req.DisplayName,
+ Encryption: req.Encryption,
+ Recipient: req.Recipient,
+ }
+ if strings.TrimSpace(req.Config) != "" {
+ configType := req.Type
+ if configType == "" {
+ configType = constant.EmailConfig
+ }
+ if configType != constant.EmailConfig {
+ return dto.AlertEmailConfig{}, fmt.Errorf("alert config test type must be email")
+ }
+ var existing *model.AlertConfig
+ if req.ID != 0 {
+ stored, err := alertRepo.GetConfigById(req.ID)
+ if err != nil {
+ return dto.AlertEmailConfig{}, err
+ }
+ existing = &stored
+ }
+ prepared, err := alertconfig.Prepare(configType, req.Config, constant.AlertEnable, existing)
+ if err != nil {
+ return dto.AlertEmailConfig{}, err
+ }
+ if err := json.Unmarshal([]byte(prepared), &emailConfig); err != nil {
+ return dto.AlertEmailConfig{}, fmt.Errorf("decode email alert config: %w", err)
+ }
+ }
+ return emailConfig, nil
+}
+
+func (a AlertService) TestCustomAlertConfig(req dto.AlertConfigTest) (dto.AlertConfigTestResult, error) {
+ if req.Type != constant.Custom {
+ return dto.AlertConfigTestResult{}, fmt.Errorf("alert config test type must be custom")
+ }
+ var existing *model.AlertConfig
+ if req.ID != 0 {
+ config, err := alertRepo.GetConfigById(req.ID)
+ if err != nil {
+ return dto.AlertConfigTestResult{}, err
+ }
+ if config.Type != constant.Custom {
+ return dto.AlertConfigTestResult{}, fmt.Errorf("alert config %d is not a custom webhook", req.ID)
+ }
+ existing = &config
+ }
+ prepared, err := alertwebhook.Prepare(req.Config, constant.AlertEnable, existing)
+ if err != nil {
+ return dto.AlertConfigTestResult{}, err
+ }
+ resolved, err := alertwebhook.Resolve(model.AlertConfig{
+ Type: constant.Custom,
+ Config: prepared.Config,
+ SecretConfig: prepared.SecretConfig,
+ })
+ if err != nil {
+ return dto.AlertConfigTestResult{}, err
+ }
+ tester, ok := xpack.AlertProvider.(providers.CustomWebhookTester)
+ if !ok {
+ return dto.AlertConfigTestResult{
+ Success: false,
+ Message: providers.ErrCustomWebhookUnsupported.Error(),
+ }, nil
+ }
+ return tester.TestCustomWebhook(resolved)
+}
+
func (a AlertService) ExternalUpdateAlert(updateAlert dto.AlertCreate, operator string) error {
- if err := a.validateCommunityAlertMethod(updateAlert.Method); err != nil {
- return err
+ var methodTypes []string
+ if updateAlert.SendCount != 0 || strings.TrimSpace(updateAlert.Method) != "" {
+ var err error
+ methodTypes, err = a.validateAlertMethodReferences(updateAlert.Method)
+ if err != nil {
+ return err
+ }
+ }
+ if updateAlert.SendCount != 0 {
+ if err := a.validateAlertMethodEntitlement(methodTypes); err != nil {
+ return err
+ }
}
upMap := make(map[string]interface{})
var newStatus string
diff --git a/agent/app/service/alert_helper.go b/agent/app/service/alert_helper.go
index f5afb42e7..e9687167a 100644
--- a/agent/app/service/alert_helper.go
+++ b/agent/app/service/alert_helper.go
@@ -695,9 +695,10 @@ func sendAlertsByConfigId(alert dto.AlertDTO, alertType, quota, quotaType string
func sendAlertsByLegacyMethod(alert dto.AlertDTO, alertType, quota, quotaType string, params []dto.Param, method string) {
typeMap := map[string]string{
- "mail": constant.Email,
- constant.Bark: constant.Bark,
- constant.SMS: constant.SMS,
+ "mail": constant.Email,
+ constant.Bark: constant.Bark,
+ constant.SMS: constant.SMS,
+ constant.Custom: constant.Custom,
}
configType, ok := typeMap[method]
if !ok {
@@ -785,7 +786,7 @@ func doSendAlert(alert dto.AlertDTO, alertType, quota, quotaType string, params
}
alertUtil.CreateNewAlertTask(quota, alertType, quotaType, methodStr)
- case constant.WeCom, constant.DingTalk, constant.FeiShu:
+ case constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Custom:
todayCount, isValid := canSendAlertToday(alertType, quotaType, alert.SendCount, methodStr)
if !isValid {
return
@@ -798,12 +799,31 @@ func doSendAlert(alert dto.AlertDTO, alertType, quota, quotaType string, params
}
transport := xpack.MultiNodeProvider.LoadRequestTransport()
agentInfo, _ := xpack.MultiNodeProvider.GetAgentInfo()
- alertErr := xpack.AlertProvider.CreateWebhookAlertLog(alertType, alert, create, quotaType, params, config, transport, agentInfo)
+ queued := false
+ var alertErr error
+ if config.Type == constant.Custom {
+ task := dto.AlertTaskMetadata{
+ AlertID: alert.ID,
+ Type: alertType,
+ Quota: quota,
+ QuotaType: quotaType,
+ Method: methodStr,
+ }
+ result, deliveryErr := xpack.DeliverCustomWebhookAlertLog(alertType, alert, create, quotaType, params, config, transport, agentInfo, task)
+ queued, alertErr = result.Queued, deliveryErr
+ if alertErr == nil && result.Queued {
+ _, alertErr = alertUtil.RecordQueuedAlertTask(result.LogID, task)
+ }
+ } else {
+ alertErr = xpack.AlertProvider.CreateWebhookAlertLog(alertType, alert, create, quotaType, params, config, transport, agentInfo)
+ }
if alertErr != nil {
global.LOG.Infof("%s alert webhook %s push faild, err: %v", alertType, methodStr, alertErr)
return
}
- alertUtil.CreateNewAlertTask(quota, alertType, quotaType, methodStr)
+ if !queued {
+ alertUtil.CreateNewAlertTask(quota, alertType, quotaType, methodStr)
+ }
}
}
diff --git a/agent/app/service/alert_sender.go b/agent/app/service/alert_sender.go
index 5a1d0fffd..f84eb33fc 100644
--- a/agent/app/service/alert_sender.go
+++ b/agent/app/service/alert_sender.go
@@ -75,7 +75,7 @@ func (s *AlertSender) sendByConfig(config model.AlertConfig, quota string, param
} else {
s.sendBarkWithConfig(config, quota, params)
}
- case constant.WeCom, constant.DingTalk, constant.FeiShu:
+ case constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Custom:
if isResource {
s.sendResourceWebhookWithConfig(config, quota, params)
} else {
@@ -86,7 +86,7 @@ func (s *AlertSender) sendByConfig(config model.AlertConfig, quota string, param
func (s *AlertSender) sendByLegacyMethod(method string, quota string, params []dto.Param, isResource bool) {
alertRepo := repo.NewIAlertRepo()
- typeMap := map[string]string{"mail": constant.Email, constant.Bark: constant.Bark, constant.SMS: constant.SMS}
+ typeMap := map[string]string{"mail": constant.Email, constant.Bark: constant.Bark, constant.SMS: constant.SMS, constant.Custom: constant.Custom}
configType := method
if mapped, ok := typeMap[method]; ok {
configType = mapped
@@ -308,12 +308,31 @@ func (s *AlertSender) sendWebhookWithConfig(config model.AlertConfig, quota stri
}
transport := xpack.MultiNodeProvider.LoadRequestTransport()
agentInfo, _ := xpack.MultiNodeProvider.GetAgentInfo()
- err := xpack.AlertProvider.CreateWebhookAlertLog(s.alert.Type, s.alert, create, quota, params, config, transport, agentInfo)
+ queued := false
+ var err error
+ if config.Type == constant.Custom {
+ task := dto.AlertTaskMetadata{
+ AlertID: s.alert.ID,
+ Type: s.alert.Type,
+ Quota: quota,
+ QuotaType: s.quotaType,
+ Method: strconv.Itoa(int(config.ID)),
+ }
+ result, deliveryErr := xpack.DeliverCustomWebhookAlertLog(s.alert.Type, s.alert, create, quota, params, config, transport, agentInfo, task)
+ queued, err = result.Queued, deliveryErr
+ if err == nil && result.Queued {
+ _, err = alertUtil.RecordQueuedAlertTask(result.LogID, task)
+ }
+ } else {
+ err = xpack.AlertProvider.CreateWebhookAlertLog(s.alert.Type, s.alert, create, quota, params, config, transport, agentInfo)
+ }
if err != nil {
global.LOG.Errorf("%s alert %s webhook push failed: %v", s.alert.Type, config.Type, err)
return
}
- alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, strconv.Itoa(int(config.ID)))
+ if !queued {
+ alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, strconv.Itoa(int(config.ID)))
+ }
}
func (s *AlertSender) sendResourceWebhookWithConfig(config model.AlertConfig, quota string, params []dto.Param) {
@@ -334,11 +353,31 @@ func (s *AlertSender) sendResourceWebhookWithConfig(config model.AlertConfig, qu
}
transport := xpack.MultiNodeProvider.LoadRequestTransport()
agentInfo, _ := xpack.MultiNodeProvider.GetAgentInfo()
- if err := xpack.AlertProvider.CreateWebhookAlertLog(s.alert.Type, s.alert, create, quota, params, config, transport, agentInfo); err != nil {
+ queued := false
+ var err error
+ if config.Type == constant.Custom {
+ task := dto.AlertTaskMetadata{
+ AlertID: s.alert.ID,
+ Type: s.alert.Type,
+ Quota: quota,
+ QuotaType: s.quotaType,
+ Method: strconv.Itoa(int(config.ID)),
+ }
+ result, deliveryErr := xpack.DeliverCustomWebhookAlertLog(s.alert.Type, s.alert, create, quota, params, config, transport, agentInfo, task)
+ queued, err = result.Queued, deliveryErr
+ if err == nil && result.Queued {
+ _, err = alertUtil.RecordQueuedAlertTask(result.LogID, task)
+ }
+ } else {
+ err = xpack.AlertProvider.CreateWebhookAlertLog(s.alert.Type, s.alert, create, quota, params, config, transport, agentInfo)
+ }
+ if err != nil {
global.LOG.Errorf("%s alert %s webhook push failed: %v", s.alert.Type, config.Type, err)
return
}
- alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, strconv.Itoa(int(config.ID)))
+ if !queued {
+ alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, strconv.Itoa(int(config.ID)))
+ }
}
func (s *AlertSender) sendWebhook(quota string, params []dto.Param, method string) {
diff --git a/agent/cmd/server/docs/x-log.json b/agent/cmd/server/docs/x-log.json
index 2a27c52c6..b3ac1bc15 100644
--- a/agent/cmd/server/docs/x-log.json
+++ b/agent/cmd/server/docs/x-log.json
@@ -5787,4 +5787,4 @@
"formatZH": "从主节点同步设置",
"formatEN": "sync settings from master"
}
-}
\ No newline at end of file
+}
diff --git a/agent/init/migration/migrate.go b/agent/init/migration/migrate.go
index a81230c51..ff31fc8b9 100644
--- a/agent/init/migration/migrate.go
+++ b/agent/init/migration/migrate.go
@@ -5,6 +5,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/init/migration/migrations"
"github.com/go-gormigrate/gormigrate/v2"
+ "gorm.io/gorm"
)
func Init() {
@@ -123,13 +124,21 @@ func InitTaskDB() {
}
func InitAlertDB() {
- m := gormigrate.New(global.AlertDB, gormigrate.DefaultOptions, []*gormigrate.Migration{
- migrations.MigrateAlertMethodConfigIDs,
- migrations.MigrateAlertLogTaskMethodConfigIDs,
- migrations.AddAlertAuditUser,
- })
- if err := m.Migrate(); err != nil {
+ if err := migrateAlertDB(global.AlertDB); err != nil {
global.LOG.Error(err)
panic(err)
}
}
+
+func migrateAlertDB(db *gorm.DB) error {
+ options := *gormigrate.DefaultOptions
+ options.UseTransaction = true
+ m := gormigrate.New(db, &options, []*gormigrate.Migration{
+ migrations.AddAlertConfigUIDAndSecret,
+ migrations.MigrateAlertMethodConfigIDs,
+ migrations.MigrateAlertLogTaskMethodConfigIDs,
+ migrations.AddAlertAuditUser,
+ migrations.AddAlertTaskDeliveryLogID,
+ })
+ return m.Migrate()
+}
diff --git a/agent/init/migration/migrations/init.go b/agent/init/migration/migrations/init.go
index bf89cb624..2a6d7d327 100644
--- a/agent/init/migration/migrations/init.go
+++ b/agent/init/migration/migrations/init.go
@@ -19,6 +19,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
migrationutils "github.com/1Panel-dev/1Panel/agent/init/migration/migrations/utils"
+ alertwebhook "github.com/1Panel-dev/1Panel/agent/utils/alert_webhook"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/copier"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
@@ -27,6 +28,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
"github.com/go-gormigrate/gormigrate/v2"
+ "github.com/google/uuid"
"gorm.io/gorm"
)
@@ -489,7 +491,7 @@ var AddColumnToAlert = &gormigrate.Migration{
var MigrateAlertMethodConfigIDs = &gormigrate.Migration{
ID: "20251001-migrate-alert-method-config-ids",
Migrate: func(tx *gorm.DB) error {
- if err := global.AlertDB.AutoMigrate(&model.Alert{}, &model.AlertLog{}, &model.AlertTask{}, &model.AlertConfig{}); err != nil {
+ if err := tx.AutoMigrate(&model.Alert{}, &model.AlertLog{}, &model.AlertTask{}, &model.AlertConfig{}); err != nil {
return err
}
if err := migrateAlertMethodConfigIDs(tx); err != nil {
@@ -502,7 +504,7 @@ var MigrateAlertMethodConfigIDs = &gormigrate.Migration{
var MigrateAlertLogTaskMethodConfigIDs = &gormigrate.Migration{
ID: "20260608-migrate-alert-log-task-method-config-ids",
Migrate: func(tx *gorm.DB) error {
- if err := global.AlertDB.AutoMigrate(&model.AlertLog{}, &model.AlertTask{}, &model.AlertConfig{}); err != nil {
+ if err := tx.AutoMigrate(&model.AlertLog{}, &model.AlertTask{}, &model.AlertConfig{}); err != nil {
return err
}
if err := migrateAlertMethodRecords(tx, &model.AlertLog{}); err != nil {
@@ -518,10 +520,104 @@ var MigrateAlertLogTaskMethodConfigIDs = &gormigrate.Migration{
var AddAlertAuditUser = &gormigrate.Migration{
ID: "20260602-add-alert-audit-user",
Migrate: func(tx *gorm.DB) error {
- return global.AlertDB.AutoMigrate(&model.Alert{}, &model.AlertConfig{})
+ return tx.AutoMigrate(&model.Alert{}, &model.AlertConfig{})
},
}
+var AddAlertConfigUIDAndSecret = &gormigrate.Migration{
+ ID: "20260826-add-alert-config-uid-secret",
+ Migrate: func(tx *gorm.DB) error {
+ return migrateAlertConfigUIDAndSecret(tx)
+ },
+}
+
+var AddAlertTaskDeliveryLogID = &gormigrate.Migration{
+ ID: "20260826-add-alert-task-delivery-log-id",
+ Migrate: func(tx *gorm.DB) error {
+ return tx.AutoMigrate(&model.AlertTask{})
+ },
+}
+
+func migrateAlertConfigUIDAndSecret(tx *gorm.DB) error {
+ if !tx.Migrator().HasTable(&model.AlertConfig{}) {
+ return tx.AutoMigrate(&model.AlertConfig{})
+ }
+
+ if !tx.Migrator().HasColumn(&model.AlertConfig{}, "UID") {
+ if err := tx.Exec("ALTER TABLE alert_configs ADD COLUMN uid varchar(64)").Error; err != nil {
+ return err
+ }
+ }
+ if !tx.Migrator().HasColumn(&model.AlertConfig{}, "SecretConfig") {
+ if err := tx.Exec("ALTER TABLE alert_configs ADD COLUMN secret_config text NOT NULL DEFAULT ''").Error; err != nil {
+ return err
+ }
+ }
+
+ var configs []struct {
+ ID uint
+ UID string
+ }
+ if err := tx.Table("alert_configs").Select("id", "uid").Find(&configs).Error; err != nil {
+ return err
+ }
+ for _, config := range configs {
+ if strings.TrimSpace(config.UID) != "" {
+ continue
+ }
+ if err := tx.Table("alert_configs").Where("id = ?", config.ID).Update("uid", uuid.NewString()).Error; err != nil {
+ return err
+ }
+ }
+
+ if err := tx.AutoMigrate(&model.AlertConfig{}); err != nil {
+ return err
+ }
+
+ var customConfigs []model.AlertConfig
+ if err := tx.Where("type = ?", constant.Custom).Find(&customConfigs).Error; err != nil {
+ return err
+ }
+ for _, config := range customConfigs {
+ prepared, status, legacy, err := alertwebhook.NormalizeLegacy(config.Config, config.Status, config.Title)
+ if err != nil {
+ return fmt.Errorf("normalize legacy custom webhook %d: %w", config.ID, err)
+ }
+ if !legacy {
+ if err := alertwebhook.ValidateStored(config); err != nil && config.Status != constant.AlertDisable {
+ if updateErr := tx.Model(&model.AlertConfig{}).Where("id = ?", config.ID).Update("status", constant.AlertDisable).Error; updateErr != nil {
+ return updateErr
+ }
+ }
+ continue
+ }
+ if err := tx.Model(&model.AlertConfig{}).Where("id = ?", config.ID).Updates(map[string]interface{}{
+ "config": prepared.Config,
+ "secret_config": prepared.SecretConfig,
+ "status": status,
+ }).Error; err != nil {
+ return err
+ }
+ }
+
+ if tx.Migrator().HasTable(&model.Alert{}) {
+ if err := migrateAlertMethodConfigIDs(tx); err != nil {
+ return err
+ }
+ }
+ if tx.Migrator().HasTable(&model.AlertLog{}) {
+ if err := migrateAlertMethodRecords(tx, &model.AlertLog{}); err != nil {
+ return err
+ }
+ }
+ if tx.Migrator().HasTable(&model.AlertTask{}) {
+ if err := migrateAlertMethodRecords(tx, &model.AlertTask{}); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
func migrateAlertMethodConfigIDs(tx *gorm.DB) error {
if err := tx.Model(&model.AlertConfig{}).Where("type = ?", "mail").Update("type", constant.EmailConfig).Error; err != nil {
return err
@@ -615,6 +711,7 @@ func alertLegacyMethodTypeMap() map[string]string {
constant.WeCom: constant.WeCom,
constant.DingTalk: constant.DingTalk,
constant.FeiShu: constant.FeiShu,
+ constant.Custom: constant.Custom,
}
}
diff --git a/agent/router/ro_alert.go b/agent/router/ro_alert.go
index 1400336c4..fa56ab6bf 100644
--- a/agent/router/ro_alert.go
+++ b/agent/router/ro_alert.go
@@ -24,6 +24,7 @@ func (a *AlertRouter) InitRouter(Router *gin.RouterGroup) {
alertRouter.POST("/cronjob/list", baseApi.GetCronJobs)
alertRouter.POST("/config/update", baseApi.UpdateAlertConfig)
+ alertRouter.POST("/config/status", baseApi.UpdateAlertConfigStatus)
alertRouter.POST("/config/info", baseApi.GetAlertConfig)
alertRouter.POST("/config/search", baseApi.PageAlertConfig)
alertRouter.POST("/config/del", baseApi.DeleteAlertConfig)
diff --git a/agent/utils/alert/alert.go b/agent/utils/alert/alert.go
index 39c9e27c6..5ea18b7ce 100644
--- a/agent/utils/alert/alert.go
+++ b/agent/utils/alert/alert.go
@@ -157,6 +157,117 @@ func CreateNewAlertTask(quota, alertType, quotaType, method string) {
global.LOG.Infof("%s alert %s push completed", alertType, method)
}
+func AttachQueuedAlertTaskMetadata(rawDetail string, task dto.AlertTaskMetadata) (string, error) {
+ if err := validateQueuedAlertTaskMetadata(task); err != nil {
+ return "", err
+ }
+ var detail dto.AlertDetail
+ if err := json.Unmarshal([]byte(rawDetail), &detail); err != nil {
+ return "", fmt.Errorf("decode queued alert detail: %w", err)
+ }
+ detail.Task = &task
+ data, err := json.Marshal(detail)
+ if err != nil {
+ return "", fmt.Errorf("encode queued alert detail: %w", err)
+ }
+ return string(data), nil
+}
+
+func RecordQueuedAlertTask(logID uint, task dto.AlertTaskMetadata) (bool, error) {
+ if logID == 0 {
+ return false, fmt.Errorf("queued alert log ID is required")
+ }
+ if global.AlertDB == nil {
+ return false, fmt.Errorf("alert database is unavailable")
+ }
+ if err := validateQueuedAlertTaskMetadata(task); err != nil {
+ return false, err
+ }
+ alertRepo := repo.NewIAlertRepo()
+ log, err := alertRepo.GetLog(repo.WithByID(logID))
+ if err != nil {
+ return false, err
+ }
+ if log.Status != constant.AlertPushing {
+ if log.Status == constant.AlertSuccess {
+ if existing, taskErr := alertRepo.GetAlertTask(alertRepo.WithByDeliveryLogID(logID)); taskErr == nil && existing.DeliveryLogID != nil {
+ return false, nil
+ }
+ }
+ return false, fmt.Errorf("alert delivery log %d is not pending", logID)
+ }
+ storedTask, alertTask, err := queuedAlertTaskFromLog(log)
+ if err != nil {
+ return false, err
+ }
+ if storedTask != task {
+ return false, fmt.Errorf("queued alert task metadata changed before reservation")
+ }
+ return alertRepo.CreatePendingAlertTask(logID, task.AlertID, &alertTask)
+}
+
+func FinalizeQueuedAlertDelivery(logID uint, succeeded bool, message string) (bool, error) {
+ if logID == 0 {
+ return false, fmt.Errorf("queued alert log ID is required")
+ }
+ if global.AlertDB == nil {
+ return false, fmt.Errorf("alert database is unavailable")
+ }
+ var fallback *model.AlertTask
+ if succeeded {
+ alertRepo := repo.NewIAlertRepo()
+ log, err := alertRepo.GetLog(repo.WithByID(logID))
+ if err != nil {
+ return false, err
+ }
+ if log.Status != constant.AlertPushing {
+ return false, nil
+ }
+ _, task, err := queuedAlertTaskFromLog(log)
+ if err != nil {
+ return false, err
+ }
+ fallback = &task
+ }
+ return repo.NewIAlertRepo().FinalizePendingAlertTask(logID, succeeded, message, fallback)
+}
+
+func queuedAlertTaskFromLog(log model.AlertLog) (dto.AlertTaskMetadata, model.AlertTask, error) {
+ var detail dto.AlertDetail
+ if err := json.Unmarshal([]byte(log.AlertDetail), &detail); err != nil {
+ return dto.AlertTaskMetadata{}, model.AlertTask{}, fmt.Errorf("decode queued alert task metadata: %w", err)
+ }
+ if detail.Task == nil {
+ return dto.AlertTaskMetadata{}, model.AlertTask{}, fmt.Errorf("queued alert task metadata is missing")
+ }
+ if err := validateQueuedAlertTaskMetadata(*detail.Task); err != nil {
+ return dto.AlertTaskMetadata{}, model.AlertTask{}, err
+ }
+ if detail.Task.AlertID != log.AlertId || detail.Task.Type != log.Type || detail.Task.Method != log.Method {
+ return dto.AlertTaskMetadata{}, model.AlertTask{}, fmt.Errorf("queued alert task metadata does not match its log")
+ }
+ task := model.AlertTask{
+ Type: detail.Task.Type,
+ Quota: detail.Task.Quota,
+ QuotaType: detail.Task.QuotaType,
+ Method: detail.Task.Method,
+ }
+ return *detail.Task, task, nil
+}
+
+func validateQueuedAlertTaskMetadata(task dto.AlertTaskMetadata) error {
+ if task.AlertID == 0 {
+ return fmt.Errorf("queued alert task alert ID is required")
+ }
+ if strings.TrimSpace(task.Type) == "" {
+ return fmt.Errorf("queued alert task type is required")
+ }
+ if strings.TrimSpace(task.Method) == "" {
+ return fmt.Errorf("queued alert task method is required")
+ }
+ return nil
+}
+
func ProcessAlertDetail(alert dto.AlertDTO, project string, params []dto.Param, method string) string {
alertDetail := dto.AlertDetail{
Type: GetCronJobType(alert.Type),
diff --git a/agent/utils/alert/custom_webhook.go b/agent/utils/alert/custom_webhook.go
new file mode 100644
index 000000000..a28393698
--- /dev/null
+++ b/agent/utils/alert/custom_webhook.go
@@ -0,0 +1,206 @@
+package alert
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/1Panel-dev/1Panel/agent/app/dto"
+ "github.com/1Panel-dev/1Panel/agent/app/model"
+ "github.com/1Panel-dev/1Panel/agent/constant"
+ "github.com/1Panel-dev/1Panel/agent/global"
+ "github.com/1Panel-dev/1Panel/agent/i18n"
+ "github.com/1Panel-dev/1Panel/agent/utils/alert_webhook"
+ "github.com/1Panel-dev/1Panel/agent/utils/webhook_sender"
+)
+
+var customWebhookNow = time.Now
+
+func CreateCustomWebhookAlertLog(
+ alertType string,
+ info dto.AlertDTO,
+ create dto.AlertLogCreate,
+ project string,
+ params []dto.Param,
+ config model.AlertConfig,
+ transport *http.Transport,
+ agentInfo *dto.AgentInfo,
+) error {
+ alertInfo := info
+ alertInfo.Type = alertType
+ create.Type = GetCronJobType(alertType)
+ create.AlertRule = ProcessAlertRule(info)
+ create.AlertDetail = ProcessAlertDetail(alertInfo, project, params, constant.Custom)
+ return deliverCustomWebhook(create, config, transport, agentInfo, customWebhookNow())
+}
+
+func CreateTaskScanCustomWebhookAlertLog(
+ info dto.AlertDTO,
+ alertType string,
+ create dto.AlertLogCreate,
+ pushAlert dto.PushAlert,
+ config model.AlertConfig,
+ transport *http.Transport,
+ agentInfo *dto.AgentInfo,
+) error {
+ params := CreateAlertParams(GetCronJobTypeName(pushAlert.Param))
+ alertInfo := info
+ alertInfo.Type = alertType
+ create.Type = GetCronJobType(alertType)
+ create.AlertRule = ProcessAlertRule(info)
+ create.AlertDetail = ProcessAlertDetail(alertInfo, pushAlert.TaskName, params, constant.Custom)
+ return deliverCustomWebhook(create, config, transport, agentInfo, customWebhookNow())
+}
+
+func deliverCustomWebhook(
+ create dto.AlertLogCreate,
+ config model.AlertConfig,
+ transport *http.Transport,
+ agentInfo *dto.AgentInfo,
+ occurredAt time.Time,
+) error {
+ var alertLog model.AlertLog
+ templateData, err := customWebhookTemplateData(create.AlertDetail, agentInfo, occurredAt)
+ if err != nil {
+ return saveCustomWebhookDeliveryError(create, &alertLog, err)
+ }
+ request, err := buildCustomWebhookRequest(config, templateData, transport)
+ if err != nil {
+ return saveCustomWebhookDeliveryError(create, &alertLog, err)
+ }
+ if _, err := webhook_sender.Execute(context.Background(), request); err != nil {
+ return saveCustomWebhookDeliveryError(create, &alertLog, err)
+ }
+ create.Status = constant.AlertSuccess
+ return SaveAlertLog(create, &alertLog)
+}
+
+func saveCustomWebhookDeliveryError(create dto.AlertLogCreate, alertLog *model.AlertLog, deliveryErr error) error {
+ create.Status = constant.AlertError
+ create.Message = deliveryErr.Error()
+ if err := SaveAlertLog(create, alertLog); err != nil {
+ global.LOG.Errorf("save custom webhook delivery error log failed: %v", err)
+ }
+ return deliveryErr
+}
+
+func customWebhookTemplateData(rawDetail string, agentInfo *dto.AgentInfo, occurredAt time.Time) (webhook_sender.TemplateData, error) {
+ var detail dto.AlertDetail
+ if err := json.Unmarshal([]byte(rawDetail), &detail); err != nil {
+ return webhook_sender.TemplateData{}, errors.New("resolve custom webhook alert detail failed")
+ }
+ businessType := detail.SubType
+ if businessType == "" {
+ businessType = detail.Type
+ }
+ if businessType == "" {
+ return webhook_sender.TemplateData{}, errors.New("resolve custom webhook alert detail failed")
+ }
+ content := GetSendContent(businessType, detail.Params, agentInfo)
+ if content == "" {
+ content = i18n.GetMsgWithMap("CommonAlert", map[string]interface{}{"msg": detail.Title})
+ }
+ message := strings.TrimSpace(webhook_sender.NormalizeToText(content))
+ if message == "" {
+ message = detail.Title
+ }
+ return webhook_sender.TemplateData{
+ Title: detail.Title,
+ Message: message,
+ Type: businessType,
+ NodeName: customWebhookNodeName(agentInfo),
+ Timestamp: occurredAt,
+ }, nil
+}
+
+func customWebhookNodeName(agentInfo *dto.AgentInfo) string {
+ if agentInfo != nil && strings.TrimSpace(agentInfo.NodeName) != "" {
+ return strings.TrimSpace(agentInfo.NodeName)
+ }
+ return strings.TrimSpace(getFallbackHostname())
+}
+
+func buildCustomWebhookRequest(
+ config model.AlertConfig,
+ data webhook_sender.TemplateData,
+ transport *http.Transport,
+) (webhook_sender.Request, error) {
+ resolved, err := alert_webhook.Resolve(config)
+ if err != nil {
+ return webhook_sender.Request{}, errors.New("resolve custom webhook config failed")
+ }
+ return buildResolvedCustomWebhookRequest(resolved, data, transport)
+}
+
+func TestCustomWebhook(
+ resolved dto.AlertCustomWebhookResolvedConfig,
+ transport *http.Transport,
+ agentInfo *dto.AgentInfo,
+) (dto.AlertConfigTestResult, error) {
+ request, err := buildResolvedCustomWebhookRequest(resolved, webhook_sender.TemplateData{
+ Title: "1Panel Webhook Test",
+ Message: "This is a test notification from 1Panel.",
+ Type: "test",
+ NodeName: customWebhookNodeName(agentInfo),
+ Timestamp: customWebhookNow(),
+ }, transport)
+ if err != nil {
+ return dto.AlertConfigTestResult{}, err
+ }
+ request.CaptureResponse = true
+ result, executeErr := webhook_sender.Execute(context.Background(), request)
+ durationMillis := result.Duration.Milliseconds()
+ if result.Duration > 0 && durationMillis == 0 {
+ durationMillis = 1
+ }
+ testResult := dto.AlertConfigTestResult{
+ Success: executeErr == nil,
+ StatusCode: result.StatusCode,
+ Duration: durationMillis,
+ Response: result.Response,
+ }
+ if executeErr != nil {
+ testResult.Message = executeErr.Error()
+ }
+ return testResult, nil
+}
+
+func buildResolvedCustomWebhookRequest(
+ resolved dto.AlertCustomWebhookResolvedConfig,
+ data webhook_sender.TemplateData,
+ transport *http.Transport,
+) (webhook_sender.Request, error) {
+ preset, err := webhook_sender.ResolvePreset(resolved.Preset)
+ if err != nil {
+ return webhook_sender.Request{}, errors.New("render custom webhook request failed")
+ }
+ format := webhook_sender.BodyFormat(resolved.Body.Type)
+ fields := make([]webhook_sender.FormField, 0, len(resolved.Body.Fields))
+ for _, field := range resolved.Body.Fields {
+ fields = append(fields, webhook_sender.FormField{Key: field.Key, Value: field.Value})
+ }
+ body, err := webhook_sender.RenderBody(webhook_sender.RenderRequest{
+ Format: format,
+ Template: resolved.Body.Template,
+ Fields: fields,
+ Data: data,
+ })
+ if err != nil {
+ return webhook_sender.Request{}, errors.New("render custom webhook request failed")
+ }
+ headers := make(map[string]string, len(resolved.Headers))
+ for _, header := range resolved.Headers {
+ headers[header.Key] = header.Value
+ }
+ return webhook_sender.Request{
+ URL: resolved.URL,
+ Preset: preset,
+ Format: format,
+ Body: body,
+ Headers: headers,
+ Transport: transport,
+ }, nil
+}
diff --git a/agent/utils/alert_config/secret.go b/agent/utils/alert_config/secret.go
new file mode 100644
index 000000000..10f44883f
--- /dev/null
+++ b/agent/utils/alert_config/secret.go
@@ -0,0 +1,312 @@
+package alert_config
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/1Panel-dev/1Panel/agent/app/model"
+ "github.com/1Panel-dev/1Panel/agent/constant"
+)
+
+const MaskedSecret = "******"
+
+type secretMutation struct {
+ Action string `json:"action"`
+ Value string `json:"value,omitempty"`
+}
+
+func IsLegacySecretType(configType string) bool {
+ return secretField(configType) != ""
+}
+
+func UsesMutation(configType, rawMutation string) (bool, error) {
+ field := secretField(configType)
+ if field == "" {
+ return false, nil
+ }
+ root, err := decodeObject(rawMutation)
+ if err != nil {
+ return false, fmt.Errorf("decode alert config mutation: %w", err)
+ }
+ if raw, ok := root[field]; ok && rawIsObject(raw) {
+ return true, nil
+ }
+ if !isWebhookType(configType) {
+ return false, nil
+ }
+ raw, ok := root["webhooks"]
+ if !ok {
+ return false, nil
+ }
+ var items []map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &items); err != nil {
+ return false, fmt.Errorf("webhooks mutation must be an array")
+ }
+ for _, item := range items {
+ if rawURL, ok := item["url"]; ok && rawIsObject(rawURL) {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+func Prepare(configType, rawMutation, status string, existing *model.AlertConfig) (string, error) {
+ field := secretField(configType)
+ if field == "" {
+ return rawMutation, nil
+ }
+ root, err := decodeObject(rawMutation)
+ if err != nil {
+ return "", fmt.Errorf("decode alert config mutation: %w", err)
+ }
+ var existingRoot map[string]json.RawMessage
+ if existing != nil {
+ if existing.Type != configType {
+ return "", fmt.Errorf("alert config %d has type %s, not %s", existing.ID, existing.Type, configType)
+ }
+ existingRoot, err = decodeObject(existing.Config)
+ if err != nil {
+ return "", fmt.Errorf("decode stored alert config: %w", err)
+ }
+ }
+
+ existingSecret, err := storedSecret(existingRoot, field)
+ if err != nil {
+ return "", err
+ }
+ if raw, ok := root[field]; ok {
+ value, err := mergeSecret(field, raw, existingSecret, existing != nil)
+ if err != nil {
+ return "", fmt.Errorf("merge alert config %s: %w", field, err)
+ }
+ root[field] = mustJSON(value)
+ } else if existing != nil {
+ root[field] = mustJSON(existingSecret)
+ }
+
+ if isWebhookType(configType) {
+ if err := mergeWebhookArray(root, existingRoot, existing != nil); err != nil {
+ return "", err
+ }
+ }
+ if status == constant.AlertEnable {
+ if configType == constant.SMSConfig {
+ phone, err := storedSecret(root, "phone")
+ if err != nil || phone == "" {
+ return "", fmt.Errorf("SMS phone is required while the config is enabled")
+ }
+ }
+ if isWebhookType(configType) && !hasWebhookURL(root) {
+ return "", fmt.Errorf("webhook URL is required while the config is enabled")
+ }
+ }
+ return encodeObject(root)
+}
+
+func secretField(configType string) string {
+ switch configType {
+ case constant.EmailConfig:
+ return "password"
+ case constant.SMSConfig:
+ return "phone"
+ case constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Bark:
+ return "url"
+ default:
+ return ""
+ }
+}
+
+func isWebhookType(configType string) bool {
+ switch configType {
+ case constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Bark:
+ return true
+ default:
+ return false
+ }
+}
+
+func decodeObject(raw string) (map[string]json.RawMessage, error) {
+ decoder := json.NewDecoder(strings.NewReader(raw))
+ decoder.DisallowUnknownFields()
+ var result map[string]json.RawMessage
+ if err := decoder.Decode(&result); err != nil {
+ return nil, err
+ }
+ if result == nil {
+ return nil, fmt.Errorf("alert config must be a JSON object")
+ }
+ var trailing any
+ if err := decoder.Decode(&trailing); err != io.EOF {
+ if err == nil {
+ return nil, fmt.Errorf("multiple JSON values are not allowed")
+ }
+ return nil, err
+ }
+ return result, nil
+}
+
+func encodeObject(value map[string]json.RawMessage) (string, error) {
+ encoded, err := json.Marshal(value)
+ if err != nil {
+ return "", fmt.Errorf("encode alert config: %w", err)
+ }
+ return string(encoded), nil
+}
+
+func mustJSON(value any) json.RawMessage {
+ encoded, err := json.Marshal(value)
+ if err != nil {
+ panic(err)
+ }
+ return encoded
+}
+
+func decodeStoredSecret(raw json.RawMessage) (string, error) {
+ var value string
+ if err := json.Unmarshal(raw, &value); err != nil {
+ return "", fmt.Errorf("stored secret is not a string")
+ }
+ return value, nil
+}
+
+func storedSecret(root map[string]json.RawMessage, field string) (string, error) {
+ if root == nil {
+ return "", nil
+ }
+ raw, ok := root[field]
+ if !ok {
+ return "", nil
+ }
+ value, err := decodeStoredSecret(raw)
+ if err != nil {
+ return "", fmt.Errorf("stored alert config %s is invalid", field)
+ }
+ return value, nil
+}
+
+func mergeSecret(field string, raw json.RawMessage, existing string, hasExisting bool) (string, error) {
+ if value, err := decodeStoredSecret(raw); err == nil {
+ if hasExisting && isLegacyMaskValue(field, value) {
+ return existing, nil
+ }
+ return value, nil
+ }
+ var mutation secretMutation
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&mutation); err != nil {
+ return "", fmt.Errorf("secret mutation must be a string or keep/replace/clear object")
+ }
+ switch mutation.Action {
+ case "keep":
+ if !hasExisting {
+ return "", fmt.Errorf("secret cannot be kept because the config does not exist")
+ }
+ return existing, nil
+ case "replace":
+ return mutation.Value, nil
+ case "clear":
+ return "", nil
+ default:
+ return "", fmt.Errorf("secret action must be keep, replace, or clear")
+ }
+}
+
+func isLegacyMaskValue(field, value string) bool {
+ if field == "phone" {
+ return isLegacyMaskedPhone(value)
+ }
+ return value == MaskedSecret
+}
+
+func isLegacyMaskedPhone(value string) bool {
+ value = strings.TrimSpace(value)
+ if !strings.Contains(value, "*") {
+ return false
+ }
+ return maskPhone(strings.ReplaceAll(value, "*", "0")) == value
+}
+
+func rawIsObject(raw json.RawMessage) bool {
+ trimmed := bytes.TrimSpace(raw)
+ return len(trimmed) > 0 && trimmed[0] == '{'
+}
+
+func mergeWebhookArray(root, existingRoot map[string]json.RawMessage, hasExisting bool) error {
+ raw, ok := root["webhooks"]
+ if !ok {
+ return nil
+ }
+ var items []map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &items); err != nil {
+ return fmt.Errorf("webhooks mutation must be an array")
+ }
+ var existingItems []map[string]json.RawMessage
+ if existingRoot != nil {
+ if existingRaw, ok := existingRoot["webhooks"]; ok {
+ if err := json.Unmarshal(existingRaw, &existingItems); err != nil {
+ return fmt.Errorf("stored webhooks must be an array")
+ }
+ }
+ }
+ for index := range items {
+ rawURL, ok := items[index]["url"]
+ if !ok {
+ continue
+ }
+ var existingURL string
+ itemExists := hasExisting && index < len(existingItems)
+ if itemExists {
+ var err error
+ existingURL, err = storedSecret(existingItems[index], "url")
+ if err != nil {
+ return fmt.Errorf("stored webhook URL %d is invalid", index)
+ }
+ }
+ value, err := mergeSecret("url", rawURL, existingURL, itemExists)
+ if err != nil {
+ return fmt.Errorf("merge webhook URL %d: %w", index, err)
+ }
+ items[index]["url"] = mustJSON(value)
+ }
+ root["webhooks"] = mustJSON(items)
+ return nil
+}
+
+func hasWebhookURL(root map[string]json.RawMessage) bool {
+ if value, err := storedSecret(root, "url"); err == nil && value != "" {
+ return true
+ }
+ raw, ok := root["webhooks"]
+ if !ok {
+ return false
+ }
+ var items []map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &items); err != nil {
+ return false
+ }
+ for _, item := range items {
+ if value, err := storedSecret(item, "url"); err == nil && value != "" {
+ return true
+ }
+ }
+ return false
+}
+
+func maskPhone(value string) string {
+ runes := []rune(strings.TrimSpace(value))
+ switch {
+ case len(runes) >= 11:
+ return string(runes[:3]) + strings.Repeat("*", len(runes)-7) + string(runes[len(runes)-4:])
+ case len(runes) >= 8:
+ return string(runes[:2]) + strings.Repeat("*", len(runes)-4) + string(runes[len(runes)-2:])
+ case utf8.RuneCountInString(value) == 0:
+ return ""
+ default:
+ return MaskedSecret
+ }
+}
diff --git a/agent/utils/alert_push/alert_push.go b/agent/utils/alert_push/alert_push.go
index abf648b9a..5f9591c17 100644
--- a/agent/utils/alert_push/alert_push.go
+++ b/agent/utils/alert_push/alert_push.go
@@ -50,9 +50,10 @@ func pushByConfigId(alertRepo repo.IAlertRepo, alert dto.AlertDTO, pushAlert dto
func pushByLegacyMethod(alertRepo repo.IAlertRepo, alert dto.AlertDTO, pushAlert dto.PushAlert, method string) {
typeMap := map[string]string{
- "mail": constant.Email,
- constant.Bark: constant.Bark,
- constant.SMS: constant.SMS,
+ "mail": constant.Email,
+ constant.Bark: constant.Bark,
+ constant.SMS: constant.SMS,
+ constant.Custom: constant.Custom,
}
configType := method
if mapped, ok := typeMap[method]; ok {
@@ -137,7 +138,7 @@ func sendAlert(alertRepo repo.IAlertRepo, alert dto.AlertDTO, pushAlert dto.Push
}
alertUtil.CreateNewAlertTask(strconv.Itoa(int(pushAlert.EntryID)), alertUtil.GetCronJobType(alert.Type), strconv.Itoa(int(pushAlert.EntryID)), methodStr)
- case constant.WeCom, constant.DingTalk, constant.FeiShu:
+ case constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Custom:
todayCount, _, err := alertRepo.LoadTaskCount(alertUtil.GetCronJobType(alert.Type), strconv.Itoa(int(pushAlert.EntryID)), methodStr)
if err != nil || alert.SendCount <= todayCount {
return
@@ -150,11 +151,29 @@ func sendAlert(alertRepo repo.IAlertRepo, alert dto.AlertDTO, pushAlert dto.Push
}
transport := xpack.MultiNodeProvider.LoadRequestTransport()
agentInfo, _ := xpack.MultiNodeProvider.GetAgentInfo()
- err = xpack.AlertProvider.CreateTaskScanWebhookAlertLog(alert, alert.Type, create, pushAlert, config, transport, agentInfo)
+ queued := false
+ if config.Type == constant.Custom {
+ task := dto.AlertTaskMetadata{
+ AlertID: alert.ID,
+ Type: alertUtil.GetCronJobType(alert.Type),
+ Quota: strconv.Itoa(int(pushAlert.EntryID)),
+ QuotaType: strconv.Itoa(int(pushAlert.EntryID)),
+ Method: methodStr,
+ }
+ result, deliveryErr := xpack.DeliverTaskScanCustomWebhookAlertLog(alert, alert.Type, create, pushAlert, config, transport, agentInfo, task)
+ queued, err = result.Queued, deliveryErr
+ if err == nil && result.Queued {
+ _, err = alertUtil.RecordQueuedAlertTask(result.LogID, task)
+ }
+ } else {
+ err = xpack.AlertProvider.CreateTaskScanWebhookAlertLog(alert, alert.Type, create, pushAlert, config, transport, agentInfo)
+ }
if err != nil {
global.LOG.Errorf("%s alert %s webhook push failed: %v", alert.Type, methodStr, err)
return
}
- alertUtil.CreateNewAlertTask(strconv.Itoa(int(pushAlert.EntryID)), alertUtil.GetCronJobType(alert.Type), strconv.Itoa(int(pushAlert.EntryID)), methodStr)
+ if !queued {
+ alertUtil.CreateNewAlertTask(strconv.Itoa(int(pushAlert.EntryID)), alertUtil.GetCronJobType(alert.Type), strconv.Itoa(int(pushAlert.EntryID)), methodStr)
+ }
}
}
diff --git a/agent/utils/alert_webhook/config.go b/agent/utils/alert_webhook/config.go
new file mode 100644
index 000000000..f1bfddc5e
--- /dev/null
+++ b/agent/utils/alert_webhook/config.go
@@ -0,0 +1,1011 @@
+package alert_webhook
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/1Panel-dev/1Panel/agent/app/dto"
+ "github.com/1Panel-dev/1Panel/agent/app/model"
+ "github.com/1Panel-dev/1Panel/agent/constant"
+ "github.com/1Panel-dev/1Panel/agent/global"
+ "github.com/1Panel-dev/1Panel/agent/utils/encrypt"
+ "github.com/google/uuid"
+)
+
+const (
+ MaskedSecret = "******"
+ DefaultGenericJSONTemplate = `{"schema_version":"1","title":"{{title}}","message":"{{message}}","type":"{{type}}","node_name":"{{nodeName}}","timestamp":"{{timestamp}}"}`
+
+ coreKeyPrefix = "core:v1:"
+ agentKeyPrefix = "agent:v1:"
+
+ maxDisplayNameRunes = 64
+ maxURLLength = 8192
+ maxHeaders = 64
+ maxHeaderNameLength = 256
+ maxHeaderValueLen = 16 * 1024
+ maxBodyLength = 256 * 1024
+ maxFormFields = 128
+ maxConfigLength = 512 * 1024
+ maxSecretLength = 256 * 1024
+)
+
+var supportedPresets = map[string]struct{}{
+ "genericJson": {},
+ "slack": {},
+ "discord": {},
+ "teamsWorkflows": {},
+ "custom": {},
+}
+
+var forbiddenHeaders = map[string]struct{}{
+ "connection": {},
+ "content-length": {},
+ "content-type": {},
+ "host": {},
+ "proxy-connection": {},
+ "proxy-authorization": {},
+ "te": {},
+ "trailer": {},
+ "transfer-encoding": {},
+ "upgrade": {},
+}
+
+var supportedTemplateVariables = map[string]struct{}{
+ "{{title}}": {},
+ "{{message}}": {},
+ "{{type}}": {},
+ "{{nodeName}}": {},
+ "{{timestamp}}": {},
+}
+
+type PreparedConfig struct {
+ Config string
+ SecretConfig string
+ DisplayName string
+}
+
+func Prepare(rawConfig, status string, existing *model.AlertConfig) (PreparedConfig, error) {
+ if len(rawConfig) > maxConfigLength {
+ return PreparedConfig{}, fmt.Errorf("custom webhook config exceeds %d bytes", maxConfigLength)
+ }
+ var mutation dto.AlertCustomWebhookConfig
+ if err := decodeStrict(rawConfig, &mutation); err != nil {
+ return PreparedConfig{}, fmt.Errorf("decode custom webhook config: %w", err)
+ }
+ if mutation.SchemaVersion != dto.AlertCustomWebhookSchemaVersion {
+ return PreparedConfig{}, fmt.Errorf("unsupported custom webhook schemaVersion: %d", mutation.SchemaVersion)
+ }
+ if mutation.State != "" {
+ return PreparedConfig{}, fmt.Errorf("custom webhook state is read-only")
+ }
+
+ existingSecret := dto.AlertCustomWebhookSecretConfig{
+ SchemaVersion: dto.AlertCustomWebhookSchemaVersion,
+ Headers: map[string]string{},
+ }
+ if existing != nil {
+ if existing.Type != constant.Custom {
+ return PreparedConfig{}, fmt.Errorf("alert config %d is not a custom webhook", existing.ID)
+ }
+ if mutationNeedsExistingSecret(mutation) {
+ var err error
+ existingSecret, err = decryptSecret(existing.SecretConfig)
+ if err != nil {
+ return PreparedConfig{}, err
+ }
+ }
+ }
+
+ stored, secret, err := mergeAndValidate(mutation, status, existingSecret, existing != nil)
+ if err != nil {
+ return PreparedConfig{}, err
+ }
+ configData, err := json.Marshal(stored)
+ if err != nil {
+ return PreparedConfig{}, fmt.Errorf("encode custom webhook config: %w", err)
+ }
+ secretData, err := json.Marshal(secret)
+ if err != nil {
+ return PreparedConfig{}, fmt.Errorf("encode custom webhook secret: %w", err)
+ }
+ if len(configData) > maxConfigLength {
+ return PreparedConfig{}, fmt.Errorf("custom webhook config exceeds %d bytes", maxConfigLength)
+ }
+ if len(secretData) > maxSecretLength {
+ return PreparedConfig{}, fmt.Errorf("custom webhook secret exceeds %d bytes", maxSecretLength)
+ }
+ secretCipher, err := encryptSecretPlain(string(secretData))
+ if err != nil {
+ return PreparedConfig{}, fmt.Errorf("encrypt custom webhook secret: %w", err)
+ }
+
+ return PreparedConfig{
+ Config: string(configData),
+ SecretConfig: secretCipher,
+ DisplayName: stored.DisplayName,
+ }, nil
+}
+
+func mutationNeedsExistingSecret(mutation dto.AlertCustomWebhookConfig) bool {
+ if mutation.URL.Action == "keep" {
+ return true
+ }
+ for _, header := range mutation.Headers {
+ if header.Secret && header.Action == "keep" {
+ return true
+ }
+ }
+ return false
+}
+
+type plainConfigView struct {
+ SchemaVersion int `json:"schemaVersion"`
+ State string `json:"state,omitempty"`
+ DisplayName string `json:"displayName"`
+ Preset string `json:"preset"`
+ Method string `json:"method"`
+ URL string `json:"url"`
+ Body dto.AlertCustomWebhookBody `json:"body"`
+ Headers []plainConfigViewHeader `json:"headers"`
+}
+
+type plainConfigViewHeader struct {
+ UID string `json:"uid"`
+ Key string `json:"key"`
+ Secret bool `json:"secret"`
+ Value string `json:"value"`
+}
+
+// PlainView decrypts a custom webhook only for the authenticated alert-config
+// read contract. The encrypted SecretConfig remains the sole persisted copy.
+func PlainView(config model.AlertConfig) (string, error) {
+ if config.Type != constant.Custom {
+ return config.Config, nil
+ }
+ stored, err := decodeStored(config.Config)
+ if err != nil {
+ if _, _, legacy := legacyValues(config.Config); legacy {
+ return config.Config, nil
+ }
+ return safeFallbackView(config, "", "invalid")
+ }
+ secret, err := validatedStoredSecret(config, stored)
+ if err != nil {
+ return safeFallbackView(config, stored.DisplayName, "invalid")
+ }
+ view := plainConfigView{
+ SchemaVersion: stored.SchemaVersion,
+ DisplayName: stored.DisplayName,
+ Preset: stored.Preset,
+ Method: stored.Method,
+ URL: secret.URL,
+ Body: stored.Body,
+ Headers: make([]plainConfigViewHeader, 0, len(stored.Headers)),
+ }
+ for _, header := range stored.Headers {
+ value := header.Value
+ if header.Secret {
+ value = secret.Headers[header.UID]
+ }
+ view.Headers = append(view.Headers, plainConfigViewHeader{
+ UID: header.UID,
+ Key: header.Key,
+ Secret: header.Secret,
+ Value: value,
+ })
+ }
+ result, err := json.Marshal(view)
+ if err != nil {
+ return "", fmt.Errorf("encode custom webhook plain view: %w", err)
+ }
+ return string(result), nil
+}
+
+func NormalizeLegacy(rawConfig, status, fallbackName string) (PreparedConfig, string, bool, error) {
+ displayName, rawURL, legacy := legacyValues(rawConfig)
+ if !legacy {
+ return PreparedConfig{}, status, false, nil
+ }
+ if displayName == "" {
+ displayName = strings.TrimSpace(fallbackName)
+ }
+ if displayName == "" || utf8.RuneCountInString(displayName) > maxDisplayNameRunes {
+ displayName = "Custom Webhook"
+ }
+ if status != constant.AlertEnable && status != constant.AlertDisable {
+ status = constant.AlertDisable
+ }
+ urlMutation := dto.AlertCustomWebhookSecretMutation{Action: "clear"}
+ if rawURL != "" {
+ if err := validateURL(rawURL); err == nil {
+ urlMutation = dto.AlertCustomWebhookSecretMutation{Action: "replace", Value: rawURL}
+ } else {
+ status = constant.AlertDisable
+ }
+ } else {
+ status = constant.AlertDisable
+ }
+ mutation := dto.AlertCustomWebhookConfig{
+ SchemaVersion: dto.AlertCustomWebhookSchemaVersion,
+ DisplayName: displayName,
+ Preset: "genericJson",
+ Method: http.MethodPost,
+ URL: dto.AlertCustomWebhookURL{AlertCustomWebhookSecretMutation: urlMutation},
+ Body: dto.AlertCustomWebhookBody{
+ Type: "json",
+ Template: DefaultGenericJSONTemplate,
+ },
+ Headers: make([]dto.AlertCustomWebhookHeader, 0),
+ }
+ data, err := json.Marshal(mutation)
+ if err != nil {
+ return PreparedConfig{}, status, true, fmt.Errorf("encode legacy custom webhook config: %w", err)
+ }
+ prepared, err := Prepare(string(data), status, nil)
+ return prepared, status, true, err
+}
+
+func safeFallbackView(config model.AlertConfig, displayName, state string) (string, error) {
+ if displayName == "" {
+ displayName = strings.TrimSpace(config.Title)
+ }
+ if displayName == "" || utf8.RuneCountInString(displayName) > maxDisplayNameRunes {
+ displayName = "Custom Webhook"
+ }
+ view := dto.AlertCustomWebhookConfig{
+ SchemaVersion: dto.AlertCustomWebhookSchemaVersion,
+ State: state,
+ DisplayName: displayName,
+ Preset: "genericJson",
+ Method: http.MethodPost,
+ URL: dto.AlertCustomWebhookURL{Configured: false},
+ Body: dto.AlertCustomWebhookBody{
+ Type: "json",
+ Template: DefaultGenericJSONTemplate,
+ },
+ Headers: make([]dto.AlertCustomWebhookHeader, 0),
+ }
+ result, err := json.Marshal(view)
+ if err != nil {
+ return "", fmt.Errorf("encode custom webhook fallback view: %w", err)
+ }
+ return string(result), nil
+}
+
+func legacyValues(rawConfig string) (string, string, bool) {
+ var raw map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(rawConfig), &raw); err != nil || raw == nil || len(raw) != 2 {
+ return "", "", false
+ }
+ displayRaw, hasDisplayName := raw["displayName"]
+ urlRaw, hasURL := raw["url"]
+ if !hasDisplayName || !hasURL {
+ return "", "", false
+ }
+ var displayName, rawURL string
+ if err := json.Unmarshal(displayRaw, &displayName); err != nil {
+ return "", "", false
+ }
+ if err := json.Unmarshal(urlRaw, &rawURL); err != nil {
+ return "", "", false
+ }
+ return strings.TrimSpace(displayName), strings.TrimSpace(rawURL), true
+}
+
+func Resolve(config model.AlertConfig) (dto.AlertCustomWebhookResolvedConfig, error) {
+ if config.Type != constant.Custom {
+ return dto.AlertCustomWebhookResolvedConfig{}, fmt.Errorf("alert config %d is not a custom webhook", config.ID)
+ }
+ stored, err := decodeStored(config.Config)
+ if err != nil {
+ return dto.AlertCustomWebhookResolvedConfig{}, err
+ }
+ secret, err := decryptSecret(config.SecretConfig)
+ if err != nil {
+ return dto.AlertCustomWebhookResolvedConfig{}, err
+ }
+ if err := validateURL(secret.URL); err != nil {
+ return dto.AlertCustomWebhookResolvedConfig{}, err
+ }
+
+ resolved := dto.AlertCustomWebhookResolvedConfig{
+ SchemaVersion: stored.SchemaVersion,
+ DisplayName: stored.DisplayName,
+ Preset: stored.Preset,
+ Method: stored.Method,
+ URL: secret.URL,
+ Body: stored.Body,
+ Headers: make([]dto.AlertCustomWebhookResolvedHeader, 0, len(stored.Headers)),
+ }
+ for _, header := range stored.Headers {
+ value := header.Value
+ if header.Secret {
+ value = secret.Headers[header.UID]
+ if value == "" && !header.Configured {
+ continue
+ }
+ if value == "" {
+ return dto.AlertCustomWebhookResolvedConfig{}, fmt.Errorf("secret header %q is not configured", header.Key)
+ }
+ }
+ resolved.Headers = append(resolved.Headers, dto.AlertCustomWebhookResolvedHeader{Key: header.Key, Value: value})
+ }
+ return resolved, nil
+}
+
+func ValidateStored(config model.AlertConfig) error {
+ if config.Type != constant.Custom {
+ return fmt.Errorf("alert config %d is not a custom webhook", config.ID)
+ }
+ stored, err := decodeStored(config.Config)
+ if err != nil {
+ return err
+ }
+ return validateStoredSecret(config, stored)
+}
+
+func ExportSecretPlain(config model.AlertConfig) (string, error) {
+ if config.Type != constant.Custom {
+ return "", fmt.Errorf("alert config %d is not a custom webhook", config.ID)
+ }
+ plainText, err := decryptSecretPlain(config.SecretConfig)
+ if err != nil {
+ return "", err
+ }
+ secret, err := decodeAndValidateSecret(plainText)
+ if err != nil {
+ return "", err
+ }
+ canonical, err := json.Marshal(secret)
+ if err != nil {
+ return "", fmt.Errorf("encode custom webhook secret: %w", err)
+ }
+ return string(canonical), nil
+}
+
+func ImportSecretPlain(plainText string) (string, error) {
+ secret, err := decodeAndValidateSecret(plainText)
+ if err != nil {
+ return "", err
+ }
+ canonical, err := json.Marshal(secret)
+ if err != nil {
+ return "", fmt.Errorf("encode custom webhook secret: %w", err)
+ }
+ return encryptSecretPlain(string(canonical))
+}
+
+func ReencryptSecret(cipherText string) (string, error) {
+ plainText, err := decryptSecretPlain(cipherText)
+ if err != nil {
+ return "", err
+ }
+ return ImportSecretPlain(plainText)
+}
+
+func mergeAndValidate(
+ mutation dto.AlertCustomWebhookConfig,
+ status string,
+ existingSecret dto.AlertCustomWebhookSecretConfig,
+ hasExisting bool,
+) (dto.AlertCustomWebhookConfig, dto.AlertCustomWebhookSecretConfig, error) {
+ mutation.DisplayName = strings.TrimSpace(mutation.DisplayName)
+ mutation.Preset = strings.TrimSpace(mutation.Preset)
+ if mutation.DisplayName == "" {
+ return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook displayName is required")
+ }
+ if utf8.RuneCountInString(mutation.DisplayName) > maxDisplayNameRunes {
+ return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook displayName exceeds %d characters", maxDisplayNameRunes)
+ }
+ if _, ok := supportedPresets[mutation.Preset]; !ok {
+ return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("unsupported custom webhook preset: %s", mutation.Preset)
+ }
+ if mutation.Method != http.MethodPost {
+ return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook method must be POST")
+ }
+ if err := validateBody(&mutation); err != nil {
+ return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, err
+ }
+
+ secret := dto.AlertCustomWebhookSecretConfig{
+ SchemaVersion: dto.AlertCustomWebhookSchemaVersion,
+ Headers: make(map[string]string),
+ }
+ switch mutation.URL.Action {
+ case "keep":
+ if !hasExisting || existingSecret.URL == "" {
+ return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook URL cannot be kept because it is not configured")
+ }
+ secret.URL = existingSecret.URL
+ case "replace":
+ secret.URL = strings.TrimSpace(mutation.URL.Value)
+ if err := validateURL(secret.URL); err != nil {
+ return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, err
+ }
+ case "clear":
+ secret.URL = ""
+ default:
+ return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook URL action must be keep, replace, or clear")
+ }
+ if secret.URL == "" && status != constant.AlertDisable {
+ return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook URL is required while the config is enabled")
+ }
+ mutation.URL = dto.AlertCustomWebhookURL{Configured: secret.URL != ""}
+
+ if len(mutation.Headers) > maxHeaders {
+ return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook headers exceed the limit of %d", maxHeaders)
+ }
+ seenUIDs := make(map[string]struct{}, len(mutation.Headers))
+ seenKeys := make(map[string]struct{}, len(mutation.Headers))
+ storedHeaders := make([]dto.AlertCustomWebhookHeader, 0, len(mutation.Headers))
+ for _, header := range mutation.Headers {
+ header.UID = strings.TrimSpace(header.UID)
+ if header.UID == "" {
+ header.UID = uuid.NewString()
+ } else if _, err := uuid.Parse(header.UID); err != nil {
+ return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook header uid must be a UUID")
+ }
+ if _, exists := seenUIDs[header.UID]; exists {
+ return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("duplicate custom webhook header uid: %s", header.UID)
+ }
+ seenUIDs[header.UID] = struct{}{}
+
+ header.Key = http.CanonicalHeaderKey(strings.TrimSpace(header.Key))
+ if err := validateHeaderName(header.Key); err != nil {
+ return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, err
+ }
+ if isSensitiveHeaderName(header.Key) && !header.Secret {
+ return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook header %q must be marked secret", header.Key)
+ }
+ keyIdentity := strings.ToLower(header.Key)
+ if _, exists := seenKeys[keyIdentity]; exists {
+ return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("duplicate custom webhook header: %s", header.Key)
+ }
+ seenKeys[keyIdentity] = struct{}{}
+
+ storedHeader := dto.AlertCustomWebhookHeader{UID: header.UID, Key: header.Key, Secret: header.Secret}
+ if header.Secret {
+ switch header.Action {
+ case "keep":
+ value, ok := existingSecret.Headers[header.UID]
+ if !hasExisting || !ok || value == "" {
+ return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("secret header %q cannot be kept because it is not configured", header.Key)
+ }
+ secret.Headers[header.UID] = value
+ case "replace":
+ if header.Value == "" {
+ return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("secret header %q value is required", header.Key)
+ }
+ if err := validateHeaderValue(header.Value); err != nil {
+ return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("header %q value is invalid", header.Key)
+ }
+ secret.Headers[header.UID] = header.Value
+ case "clear":
+ default:
+ return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("secret header %q action must be keep, replace, or clear", header.Key)
+ }
+ storedHeader.Configured = secret.Headers[header.UID] != ""
+ } else {
+ if header.Action != "replace" {
+ return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("non-secret header %q action must be replace", header.Key)
+ }
+ if err := validateHeaderValue(header.Value); err != nil {
+ return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("header %q value is invalid", header.Key)
+ }
+ storedHeader.Value = header.Value
+ }
+ storedHeaders = append(storedHeaders, storedHeader)
+ }
+ mutation.Headers = storedHeaders
+ mutation.SchemaVersion = dto.AlertCustomWebhookSchemaVersion
+ return mutation, secret, nil
+}
+
+func validateStoredSecret(config model.AlertConfig, stored dto.AlertCustomWebhookConfig) error {
+ _, err := validatedStoredSecret(config, stored)
+ return err
+}
+
+func validatedStoredSecret(config model.AlertConfig, stored dto.AlertCustomWebhookConfig) (dto.AlertCustomWebhookSecretConfig, error) {
+ if config.Status != constant.AlertEnable && config.Status != constant.AlertDisable {
+ return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("stored custom webhook status is invalid")
+ }
+ if strings.TrimSpace(config.SecretConfig) == "" {
+ return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("stored custom webhook secret is missing")
+ }
+ secret, err := decryptSecret(config.SecretConfig)
+ if err != nil {
+ return dto.AlertCustomWebhookSecretConfig{}, err
+ }
+ if secret.URL != "" {
+ if err := validateURL(secret.URL); err != nil {
+ return dto.AlertCustomWebhookSecretConfig{}, err
+ }
+ }
+ if stored.URL.Configured != (secret.URL != "") {
+ return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("stored custom webhook URL state does not match its secret")
+ }
+ if config.Status == constant.AlertEnable && secret.URL == "" {
+ return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook URL is required while the config is enabled")
+ }
+ usedSecrets := make(map[string]struct{}, len(stored.Headers))
+ for _, header := range stored.Headers {
+ if !header.Secret {
+ continue
+ }
+ value, configured := secret.Headers[header.UID]
+ if header.Configured != (configured && value != "") {
+ return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("stored custom webhook secret header state is inconsistent")
+ }
+ if configured {
+ usedSecrets[header.UID] = struct{}{}
+ }
+ }
+ if len(usedSecrets) != len(secret.Headers) {
+ return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("stored custom webhook contains orphaned header secrets")
+ }
+ return secret, nil
+}
+
+func validateBody(config *dto.AlertCustomWebhookConfig) error {
+ config.Body.Type = strings.TrimSpace(config.Body.Type)
+ if config.Preset != "custom" && config.Body.Type != "json" {
+ return fmt.Errorf("custom webhook preset %s requires a JSON body", config.Preset)
+ }
+ switch config.Body.Type {
+ case "json":
+ if config.Body.Template == "" {
+ return fmt.Errorf("json custom webhook body template is required")
+ }
+ if len(config.Body.Template) > maxBodyLength {
+ return fmt.Errorf("custom webhook body exceeds %d bytes", maxBodyLength)
+ }
+ if !json.Valid([]byte(config.Body.Template)) {
+ return fmt.Errorf("custom webhook JSON body template must be valid JSON")
+ }
+ var document any
+ if err := json.Unmarshal([]byte(config.Body.Template), &document); err != nil {
+ return fmt.Errorf("custom webhook JSON body template must be valid JSON")
+ }
+ if containsTemplateJSONKey(document) {
+ return fmt.Errorf("custom webhook JSON body keys cannot contain template variables")
+ }
+ if err := validateTemplateVariables(config.Body.Template); err != nil {
+ return err
+ }
+ if len(config.Body.Fields) != 0 {
+ return fmt.Errorf("json custom webhook body does not accept form fields")
+ }
+ case "form":
+ if config.Body.Template != "" {
+ return fmt.Errorf("form custom webhook body does not accept a template")
+ }
+ if len(config.Body.Fields) == 0 {
+ return fmt.Errorf("form custom webhook body requires at least one field")
+ }
+ if len(config.Body.Fields) > maxFormFields {
+ return fmt.Errorf("custom webhook form fields exceed the limit of %d", maxFormFields)
+ }
+ seen := make(map[string]struct{}, len(config.Body.Fields))
+ for index := range config.Body.Fields {
+ field := &config.Body.Fields[index]
+ field.Key = strings.TrimSpace(field.Key)
+ if field.Key == "" {
+ return fmt.Errorf("custom webhook form field key is required")
+ }
+ if strings.Contains(field.Key, "{{") || strings.Contains(field.Key, "}}") {
+ return fmt.Errorf("custom webhook form field keys cannot contain template variables")
+ }
+ if len(field.Key) > maxHeaderNameLength || len(field.Value) > maxHeaderValueLen {
+ return fmt.Errorf("custom webhook form field %q is too long", field.Key)
+ }
+ identity := strings.ToLower(field.Key)
+ if _, exists := seen[identity]; exists {
+ return fmt.Errorf("duplicate custom webhook form field: %s", field.Key)
+ }
+ seen[identity] = struct{}{}
+ if err := validateTemplateVariables(field.Value); err != nil {
+ return err
+ }
+ }
+ case "text":
+ if config.Body.Template == "" {
+ return fmt.Errorf("text custom webhook body template is required")
+ }
+ if len(config.Body.Template) > maxBodyLength {
+ return fmt.Errorf("custom webhook body exceeds %d bytes", maxBodyLength)
+ }
+ if err := validateTemplateVariables(config.Body.Template); err != nil {
+ return err
+ }
+ if len(config.Body.Fields) != 0 {
+ return fmt.Errorf("text custom webhook body does not accept form fields")
+ }
+ default:
+ return fmt.Errorf("custom webhook body type must be json, form, or text")
+ }
+ return nil
+}
+
+func containsTemplateJSONKey(value any) bool {
+ switch typed := value.(type) {
+ case map[string]any:
+ for key, child := range typed {
+ if strings.Contains(key, "{{") || strings.Contains(key, "}}") || containsTemplateJSONKey(child) {
+ return true
+ }
+ }
+ case []any:
+ for _, child := range typed {
+ if containsTemplateJSONKey(child) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func validateTemplateVariables(template string) error {
+ remainder := template
+ for {
+ start := strings.Index(remainder, "{{")
+ if start < 0 {
+ if strings.Contains(remainder, "}}") {
+ return fmt.Errorf("custom webhook body contains a malformed template variable")
+ }
+ return nil
+ }
+ if strings.Contains(remainder[:start], "}}") {
+ return fmt.Errorf("custom webhook body contains a malformed template variable")
+ }
+ endOffset := strings.Index(remainder[start+2:], "}}")
+ if endOffset < 0 {
+ return fmt.Errorf("custom webhook body contains a malformed template variable")
+ }
+ end := start + 2 + endOffset + 2
+ variable := remainder[start:end]
+ if _, supported := supportedTemplateVariables[variable]; !supported {
+ return fmt.Errorf("custom webhook body contains an unsupported template variable")
+ }
+ remainder = remainder[end:]
+ }
+}
+
+func ContentTypeForBodyType(bodyType string) (string, error) {
+ switch bodyType {
+ case "json":
+ return "application/json", nil
+ case "form":
+ return "application/x-www-form-urlencoded", nil
+ case "text":
+ return "text/plain", nil
+ default:
+ return "", fmt.Errorf("custom webhook body type must be json, form, or text")
+ }
+}
+
+func validateURL(rawURL string) error {
+ if rawURL == "" {
+ return fmt.Errorf("custom webhook URL is required")
+ }
+ if len(rawURL) > maxURLLength {
+ return fmt.Errorf("custom webhook URL exceeds %d bytes", maxURLLength)
+ }
+ parsed, err := url.Parse(strings.TrimSpace(rawURL))
+ if err != nil {
+ return fmt.Errorf("invalid custom webhook URL")
+ }
+ if parsed.Scheme != "http" && parsed.Scheme != "https" {
+ return fmt.Errorf("custom webhook URL scheme must be http or https")
+ }
+ hostname := strings.TrimSuffix(strings.TrimSpace(parsed.Hostname()), ".")
+ if parsed.Host == "" || hostname == "" || strings.Contains(hostname, "%") {
+ return fmt.Errorf("custom webhook URL host is required")
+ }
+ if strings.HasSuffix(parsed.Host, ":") {
+ return fmt.Errorf("custom webhook URL port is invalid")
+ }
+ if port := parsed.Port(); port != "" {
+ value, err := strconv.Atoi(port)
+ if err != nil || value < 1 || value > 65535 {
+ return fmt.Errorf("custom webhook URL port is invalid")
+ }
+ }
+ if parsed.User != nil {
+ return fmt.Errorf("custom webhook URL must not contain user info")
+ }
+ if parsed.Fragment != "" {
+ return fmt.Errorf("custom webhook URL must not contain a fragment")
+ }
+ return nil
+}
+
+func validateHeaderName(name string) error {
+ if name == "" {
+ return fmt.Errorf("custom webhook header name is required")
+ }
+ if len(name) > maxHeaderNameLength {
+ return fmt.Errorf("custom webhook header name is too long")
+ }
+ for index := 0; index < len(name); index++ {
+ if !isHeaderTokenByte(name[index]) {
+ return fmt.Errorf("invalid custom webhook header name: %s", name)
+ }
+ }
+ if _, forbidden := forbiddenHeaders[strings.ToLower(name)]; forbidden {
+ return fmt.Errorf("custom webhook header %q is managed by the sender", name)
+ }
+ return nil
+}
+
+func validateHeaderValue(value string) error {
+ if len(value) > maxHeaderValueLen || strings.ContainsAny(value, "\r\n") {
+ return fmt.Errorf("invalid custom webhook header value")
+ }
+ return nil
+}
+
+func isSensitiveHeaderName(name string) bool {
+ normalized := strings.ToLower(strings.TrimSpace(name))
+ compact := strings.NewReplacer("-", "", "_", "").Replace(normalized)
+ return normalized == "authorization" ||
+ normalized == "cookie" ||
+ strings.Contains(compact, "token") ||
+ strings.Contains(compact, "secret") ||
+ strings.Contains(compact, "signature") ||
+ strings.Contains(compact, "apikey") ||
+ strings.HasSuffix(normalized, "-key") ||
+ strings.HasSuffix(normalized, "_key")
+}
+
+func isHeaderTokenByte(char byte) bool {
+ if char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' {
+ return true
+ }
+ return strings.ContainsRune("!#$%&'*+-.^_`|~", rune(char))
+}
+
+func decodeStored(rawConfig string) (dto.AlertCustomWebhookConfig, error) {
+ if len(rawConfig) > maxConfigLength {
+ return dto.AlertCustomWebhookConfig{}, fmt.Errorf("stored custom webhook config exceeds %d bytes", maxConfigLength)
+ }
+ var stored dto.AlertCustomWebhookConfig
+ if err := decodeStrict(rawConfig, &stored); err != nil {
+ return dto.AlertCustomWebhookConfig{}, fmt.Errorf("decode stored custom webhook config: %w", err)
+ }
+ if stored.SchemaVersion != dto.AlertCustomWebhookSchemaVersion {
+ return dto.AlertCustomWebhookConfig{}, fmt.Errorf("unsupported stored custom webhook schemaVersion: %d", stored.SchemaVersion)
+ }
+ if stored.State != "" {
+ return dto.AlertCustomWebhookConfig{}, fmt.Errorf("stored custom webhook contains a view state")
+ }
+ if stored.URL.Action != "" || stored.URL.Value != "" {
+ return dto.AlertCustomWebhookConfig{}, fmt.Errorf("stored custom webhook URL contains a mutation")
+ }
+ for _, header := range stored.Headers {
+ if header.Action != "" || header.Secret && header.Value != "" {
+ return dto.AlertCustomWebhookConfig{}, fmt.Errorf("stored custom webhook header %q contains a mutation", header.Key)
+ }
+ }
+ if err := validateStoredConfig(&stored); err != nil {
+ return dto.AlertCustomWebhookConfig{}, err
+ }
+ return stored, nil
+}
+
+func validateStoredConfig(config *dto.AlertCustomWebhookConfig) error {
+ config.DisplayName = strings.TrimSpace(config.DisplayName)
+ if config.DisplayName == "" || utf8.RuneCountInString(config.DisplayName) > maxDisplayNameRunes {
+ return fmt.Errorf("stored custom webhook displayName is invalid")
+ }
+ if _, ok := supportedPresets[config.Preset]; !ok {
+ return fmt.Errorf("unsupported stored custom webhook preset: %s", config.Preset)
+ }
+ if config.Method != http.MethodPost {
+ return fmt.Errorf("stored custom webhook method must be POST")
+ }
+ if err := validateBody(config); err != nil {
+ return err
+ }
+ seenUIDs := make(map[string]struct{}, len(config.Headers))
+ seenKeys := make(map[string]struct{}, len(config.Headers))
+ for index := range config.Headers {
+ header := &config.Headers[index]
+ if header.UID == "" {
+ return fmt.Errorf("stored custom webhook header uid is required")
+ }
+ if _, err := uuid.Parse(header.UID); err != nil {
+ return fmt.Errorf("stored custom webhook header uid must be a UUID")
+ }
+ if _, exists := seenUIDs[header.UID]; exists {
+ return fmt.Errorf("duplicate stored custom webhook header uid: %s", header.UID)
+ }
+ seenUIDs[header.UID] = struct{}{}
+ header.Key = http.CanonicalHeaderKey(strings.TrimSpace(header.Key))
+ if err := validateHeaderName(header.Key); err != nil {
+ return err
+ }
+ if isSensitiveHeaderName(header.Key) && !header.Secret {
+ return fmt.Errorf("stored custom webhook header %q must be marked secret", header.Key)
+ }
+ identity := strings.ToLower(header.Key)
+ if _, exists := seenKeys[identity]; exists {
+ return fmt.Errorf("duplicate stored custom webhook header: %s", header.Key)
+ }
+ seenKeys[identity] = struct{}{}
+ if !header.Secret {
+ if err := validateHeaderValue(header.Value); err != nil {
+ return fmt.Errorf("stored header %q value is invalid", header.Key)
+ }
+ }
+ }
+ return nil
+}
+
+func decryptSecret(cipherText string) (dto.AlertCustomWebhookSecretConfig, error) {
+ if strings.TrimSpace(cipherText) == "" {
+ return dto.AlertCustomWebhookSecretConfig{
+ SchemaVersion: dto.AlertCustomWebhookSchemaVersion,
+ Headers: map[string]string{},
+ }, nil
+ }
+ plainText, err := decryptSecretPlain(cipherText)
+ if err != nil {
+ return dto.AlertCustomWebhookSecretConfig{}, err
+ }
+ return decodeAndValidateSecret(plainText)
+}
+
+func encryptSecretPlain(plainText string) (string, error) {
+ coreKey, coreAvailable, err := loadCoreEncryptKey()
+ if err != nil {
+ return "", err
+ }
+ if coreAvailable {
+ cipherText, err := encrypt.StringEncryptWithKey(plainText, coreKey)
+ if err != nil {
+ return "", fmt.Errorf("encrypt custom webhook secret with core key: %w", err)
+ }
+ return coreKeyPrefix + cipherText, nil
+ }
+ key, err := loadAgentEncryptKey()
+ if err != nil {
+ return "", err
+ }
+ cipherText, err := encrypt.StringEncryptWithKey(plainText, key)
+ if err != nil {
+ return "", fmt.Errorf("encrypt custom webhook secret with agent key: %w", err)
+ }
+ return agentKeyPrefix + cipherText, nil
+}
+
+func decryptSecretPlain(cipherText string) (string, error) {
+ if strings.TrimSpace(cipherText) == "" {
+ return "", fmt.Errorf("custom webhook secret is empty")
+ }
+ var (
+ payload string
+ key string
+ err error
+ )
+ switch {
+ case strings.HasPrefix(cipherText, coreKeyPrefix):
+ payload = strings.TrimPrefix(cipherText, coreKeyPrefix)
+ var available bool
+ key, available, err = loadCoreEncryptKey()
+ if err != nil {
+ return "", err
+ }
+ if !available {
+ return "", fmt.Errorf("decrypt custom webhook secret: core encrypt key is unavailable")
+ }
+ case strings.HasPrefix(cipherText, agentKeyPrefix):
+ payload = strings.TrimPrefix(cipherText, agentKeyPrefix)
+ key, err = loadAgentEncryptKey()
+ if err != nil {
+ return "", err
+ }
+ default:
+ payload = cipherText
+ key, err = loadAgentEncryptKey()
+ if err != nil {
+ return "", err
+ }
+ }
+ if payload == "" {
+ return "", fmt.Errorf("decrypt custom webhook secret: ciphertext is empty")
+ }
+ plainText, err := encrypt.StringDecryptWithKey(payload, key)
+ if err != nil {
+ return "", fmt.Errorf("decrypt custom webhook secret: %w", err)
+ }
+ return plainText, nil
+}
+
+func loadCoreEncryptKey() (string, bool, error) {
+ if global.CoreDB == nil {
+ return "", false, nil
+ }
+ var setting model.Setting
+ if err := global.CoreDB.Where("key = ?", "EncryptKey").First(&setting).Error; err != nil {
+ return "", true, fmt.Errorf("custom webhook core encrypt key is unavailable")
+ }
+ key := strings.TrimSpace(setting.Value)
+ if key == "" {
+ return "", true, fmt.Errorf("custom webhook core encrypt key is empty")
+ }
+ return key, true, nil
+}
+
+func loadAgentEncryptKey() (string, error) {
+ if key := strings.TrimSpace(global.CONF.Base.EncryptKey); key != "" {
+ return key, nil
+ }
+ if global.DB != nil {
+ var setting model.Setting
+ if err := global.DB.Where("key = ?", "EncryptKey").First(&setting).Error; err == nil {
+ if key := strings.TrimSpace(setting.Value); key != "" {
+ return key, nil
+ }
+ }
+ }
+ return "", fmt.Errorf("custom webhook agent encrypt key is empty")
+}
+
+func decodeAndValidateSecret(plainText string) (dto.AlertCustomWebhookSecretConfig, error) {
+ if len(plainText) > maxSecretLength {
+ return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook secret exceeds %d bytes", maxSecretLength)
+ }
+ var secret dto.AlertCustomWebhookSecretConfig
+ if err := decodeStrict(plainText, &secret); err != nil {
+ return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("decode custom webhook secret: %w", err)
+ }
+ if secret.SchemaVersion != dto.AlertCustomWebhookSchemaVersion {
+ return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("unsupported custom webhook secret schemaVersion: %d", secret.SchemaVersion)
+ }
+ if secret.URL != "" {
+ if err := validateURL(secret.URL); err != nil {
+ return dto.AlertCustomWebhookSecretConfig{}, err
+ }
+ }
+ if secret.Headers == nil {
+ secret.Headers = map[string]string{}
+ }
+ if len(secret.Headers) > maxHeaders {
+ return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook secret headers exceed the limit of %d", maxHeaders)
+ }
+ for uid, value := range secret.Headers {
+ if _, err := uuid.Parse(strings.TrimSpace(uid)); err != nil {
+ return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook secret header uid must be a UUID")
+ }
+ if value == "" {
+ return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook secret header %q is empty", uid)
+ }
+ if err := validateHeaderValue(value); err != nil {
+ return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook secret header %q is invalid", uid)
+ }
+ }
+ return secret, nil
+}
+
+func decodeStrict(raw string, target any) error {
+ decoder := json.NewDecoder(strings.NewReader(raw))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(target); err != nil {
+ return err
+ }
+ if err := decoder.Decode(&struct{}{}); err != io.EOF {
+ if err == nil {
+ return fmt.Errorf("multiple JSON values are not allowed")
+ }
+ return err
+ }
+ return nil
+}
diff --git a/agent/utils/webhook_sender/render.go b/agent/utils/webhook_sender/render.go
new file mode 100644
index 000000000..ad91eda58
--- /dev/null
+++ b/agent/utils/webhook_sender/render.go
@@ -0,0 +1,185 @@
+package webhook_sender
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/url"
+ "strings"
+ "time"
+)
+
+const MaxRenderedBodyBytes = 256 * 1024
+
+type TemplateData struct {
+ Title string
+ Message string
+ Type string
+ Timestamp time.Time
+ NodeName string
+}
+
+type FormField struct {
+ Key string
+ Value string
+}
+
+type RenderRequest struct {
+ Format BodyFormat
+ Template string
+ Fields []FormField
+ Data TemplateData
+}
+
+func RenderBody(input RenderRequest) ([]byte, error) {
+ values := templateValues(input.Data)
+ var rendered []byte
+ var err error
+ switch input.Format {
+ case BodyJSON:
+ rendered, err = renderJSONTemplate(input.Template, values)
+ if err != nil {
+ return nil, errors.New("rendered webhook JSON body is invalid")
+ }
+ case BodyText:
+ var text string
+ text, err = renderRestrictedTemplate(input.Template, values)
+ if err != nil {
+ return nil, err
+ }
+ rendered = []byte(text)
+ case BodyForm:
+ form := make(url.Values, len(input.Fields))
+ for _, field := range input.Fields {
+ if strings.Contains(field.Key, "{{") || strings.Contains(field.Key, "}}") {
+ return nil, errors.New("webhook form field key cannot contain template variables")
+ }
+ value, err := renderRestrictedTemplate(field.Value, values)
+ if err != nil {
+ return nil, err
+ }
+ form.Add(field.Key, value)
+ }
+ rendered = []byte(form.Encode())
+ default:
+ return nil, errors.New("unsupported webhook body format")
+ }
+ if len(rendered) > MaxRenderedBodyBytes {
+ return nil, errors.New("rendered webhook body exceeded size limit")
+ }
+ return rendered, nil
+}
+
+func ResolvePreset(value string) (Preset, error) {
+ switch strings.TrimSpace(value) {
+ case "", "generic", "genericJson", "custom":
+ return PresetGeneric, nil
+ case "slack":
+ return PresetSlack, nil
+ case "discord":
+ return PresetDiscord, nil
+ case "teams", "teamsWorkflows":
+ return PresetTeams, nil
+ default:
+ return "", errors.New("unsupported webhook preset")
+ }
+}
+
+func templateValues(data TemplateData) map[string]string {
+ timestamp := data.Timestamp
+ if timestamp.IsZero() {
+ timestamp = time.Now()
+ }
+ return map[string]string{
+ "title": data.Title,
+ "message": data.Message,
+ "type": data.Type,
+ "timestamp": timestamp.Format(time.RFC3339),
+ "nodeName": data.NodeName,
+ }
+}
+
+func renderJSONTemplate(source string, values map[string]string) ([]byte, error) {
+ decoder := json.NewDecoder(strings.NewReader(source))
+ decoder.UseNumber()
+ var document any
+ if err := decoder.Decode(&document); err != nil {
+ return nil, errors.New("invalid webhook JSON template")
+ }
+ if err := ensureJSONEOF(decoder); err != nil {
+ return nil, err
+ }
+ rendered, err := renderJSONValue(document, values)
+ if err != nil {
+ return nil, err
+ }
+ result, err := json.Marshal(rendered)
+ if err != nil {
+ return nil, errors.New("marshal rendered webhook JSON body failed")
+ }
+ return result, nil
+}
+
+func ensureJSONEOF(decoder *json.Decoder) error {
+ var extra any
+ if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
+ return errors.New("invalid webhook JSON template")
+ }
+ return nil
+}
+
+func renderJSONValue(value any, values map[string]string) (any, error) {
+ switch typed := value.(type) {
+ case string:
+ return renderRestrictedTemplate(typed, values)
+ case []any:
+ for index := range typed {
+ rendered, err := renderJSONValue(typed[index], values)
+ if err != nil {
+ return nil, err
+ }
+ typed[index] = rendered
+ }
+ return typed, nil
+ case map[string]any:
+ for key, item := range typed {
+ rendered, err := renderJSONValue(item, values)
+ if err != nil {
+ return nil, err
+ }
+ typed[key] = rendered
+ }
+ return typed, nil
+ default:
+ return value, nil
+ }
+}
+
+func renderRestrictedTemplate(source string, values map[string]string) (string, error) {
+ var output bytes.Buffer
+ for len(source) != 0 {
+ start := strings.Index(source, "{{")
+ if start == -1 {
+ if strings.Contains(source, "}}") {
+ return "", errors.New("invalid webhook template placeholder")
+ }
+ output.WriteString(source)
+ break
+ }
+ output.WriteString(source[:start])
+ source = source[start+2:]
+ end := strings.Index(source, "}}")
+ if end == -1 {
+ return "", errors.New("invalid webhook template placeholder")
+ }
+ name := strings.TrimSpace(source[:end])
+ value, ok := values[name]
+ if !ok || name == "" || strings.Contains(name, "{{") {
+ return "", errors.New("unsupported webhook template variable")
+ }
+ output.WriteString(value)
+ source = source[end+2:]
+ }
+ return output.String(), nil
+}
diff --git a/agent/utils/webhook_sender/request.go b/agent/utils/webhook_sender/request.go
new file mode 100644
index 000000000..51426d1da
--- /dev/null
+++ b/agent/utils/webhook_sender/request.go
@@ -0,0 +1,767 @@
+package webhook_sender
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "net/netip"
+ "net/url"
+ "sort"
+ "strings"
+ "time"
+ "unicode/utf8"
+)
+
+const (
+ RequestTimeout = 10 * time.Second
+ MaxResponseBodyBytes = 64 * 1024
+ MaxCapturedResponseBytes = 2 * 1024
+)
+
+var loadWebhookSystemCertPool = x509.SystemCertPool
+
+type Preset string
+
+const (
+ PresetGeneric Preset = "generic"
+ PresetSlack Preset = "slack"
+ PresetDiscord Preset = "discord"
+ PresetTeams Preset = "teams"
+)
+
+type BodyFormat string
+
+const (
+ BodyJSON BodyFormat = "json"
+ BodyForm BodyFormat = "form"
+ BodyText BodyFormat = "text"
+)
+
+type Request struct {
+ URL string
+ Preset Preset
+ Format BodyFormat
+ Body []byte
+ Headers map[string]string
+ Transport *http.Transport
+ Resolver IPResolver
+ CaptureResponse bool
+}
+
+type IPResolver interface {
+ LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error)
+}
+
+type Result struct {
+ StatusCode int
+ ResponseSize int
+ Duration time.Duration
+ Response string
+}
+
+func Execute(ctx context.Context, input Request) (Result, error) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ contentType, err := validateRenderedBody(input.Format, input.Body)
+ if err != nil {
+ return Result{}, err
+ }
+ if err := validateHeaders(input.Headers); err != nil {
+ return Result{}, err
+ }
+ requestContext, cancel := context.WithTimeout(ctx, RequestTimeout)
+ defer cancel()
+
+ target, err := prepareTarget(requestContext, input.URL, input.Preset, input.Resolver)
+ if err != nil {
+ return Result{}, errors.New("invalid webhook request URL")
+ }
+
+ req, err := http.NewRequestWithContext(requestContext, http.MethodPost, target.URL, bytes.NewReader(input.Body))
+ if err != nil {
+ return Result{}, errors.New("create webhook request failed")
+ }
+ req.Host = target.HostHeader
+ for name, value := range input.Headers {
+ req.Header.Set(name, value)
+ }
+
+ req.Header.Set("Content-Type", contentType)
+
+ transport := systemTLSTransport(input.Transport, target.ServerName, target.OriginalURL, target.Addresses)
+ var roundTripper http.RoundTripper = transport
+ if target.OriginalURL != nil && target.OriginalURL.Scheme == "http" {
+ roundTripper = &pinnedHTTPProxyRoundTripper{transport: transport, originalHost: target.HostHeader}
+ }
+ client := &http.Client{
+ Timeout: RequestTimeout,
+ Transport: roundTripper,
+ CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+ startedAt := time.Now()
+ resp, err := client.Do(req)
+ duration := time.Since(startedAt)
+ if err != nil {
+ if errors.Is(err, context.DeadlineExceeded) {
+ return Result{Duration: duration}, errors.New("webhook request timed out")
+ }
+ if errors.Is(err, context.Canceled) {
+ return Result{Duration: duration}, errors.New("webhook request canceled")
+ }
+ return Result{Duration: duration}, errors.New("webhook request failed")
+ }
+ defer resp.Body.Close()
+
+ responseBody, err := io.ReadAll(io.LimitReader(resp.Body, MaxResponseBodyBytes+1))
+ duration = time.Since(startedAt)
+ result := Result{
+ StatusCode: resp.StatusCode,
+ ResponseSize: len(responseBody),
+ Duration: duration,
+ }
+ if input.CaptureResponse {
+ result.Response = captureResponse(responseBody, input, target)
+ }
+ if err != nil {
+ return result, errors.New("read webhook response failed")
+ }
+ if len(responseBody) > MaxResponseBodyBytes {
+ return result, errors.New("webhook response exceeded size limit")
+ }
+
+ if err := validateResponse(input.Preset, resp.StatusCode, responseBody); err != nil {
+ return result, err
+ }
+ return result, nil
+}
+
+type pinnedHTTPProxyRoundTripper struct {
+ transport *http.Transport
+ originalHost string
+}
+
+func (p *pinnedHTTPProxyRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
+ if p.transport.Proxy == nil {
+ return p.transport.RoundTrip(request)
+ }
+ proxyURL, err := p.transport.Proxy(request)
+ if err != nil {
+ return nil, errors.New("select webhook proxy failed")
+ }
+ if proxyURL == nil {
+ direct := p.transport.Clone()
+ direct.Proxy = nil
+ return direct.RoundTrip(request)
+ }
+ return p.roundTripProxy(request, proxyURL)
+}
+
+func (p *pinnedHTTPProxyRoundTripper) roundTripProxy(request *http.Request, proxyURL *url.URL) (*http.Response, error) {
+ proxyAddress, err := proxyDialAddress(proxyURL)
+ if err != nil {
+ return nil, err
+ }
+ dialContext := p.transport.DialContext
+ if dialContext == nil {
+ dialContext = (&net.Dialer{}).DialContext
+ }
+ connection, err := dialContext(request.Context(), "tcp", proxyAddress)
+ if err != nil {
+ return nil, errors.New("connect webhook proxy failed")
+ }
+ closeConnection := true
+ defer func() {
+ if closeConnection {
+ _ = connection.Close()
+ }
+ }()
+ if deadline, ok := request.Context().Deadline(); ok {
+ _ = connection.SetDeadline(deadline)
+ }
+ if proxyURL.Scheme == "https" {
+ proxyTLSConfig := &tls.Config{ServerName: proxyURL.Hostname()}
+ if roots, rootsErr := loadWebhookSystemCertPool(); rootsErr == nil {
+ proxyTLSConfig.RootCAs = roots
+ }
+ tlsConnection := tls.Client(connection, proxyTLSConfig)
+ if err := tlsConnection.HandshakeContext(request.Context()); err != nil {
+ return nil, errors.New("connect webhook proxy failed")
+ }
+ connection = tlsConnection
+ }
+
+ connectAuthority, err := pinnedHTTPConnectAuthority(request.URL)
+ if err != nil {
+ return nil, err
+ }
+ connectRequest := &http.Request{
+ Method: http.MethodConnect,
+ URL: &url.URL{Opaque: connectAuthority},
+ Host: connectAuthority,
+ Header: make(http.Header),
+ }
+ if err := p.addProxyHeaders(connectRequest, proxyURL); err != nil {
+ return nil, err
+ }
+ if err := connectRequest.Write(connection); err != nil {
+ return nil, errors.New("connect webhook proxy failed")
+ }
+ reader := bufio.NewReader(connection)
+ connectResponse, err := http.ReadResponse(reader, connectRequest)
+ if err != nil {
+ return nil, errors.New("connect webhook proxy failed")
+ }
+ if p.transport.OnProxyConnectResponse != nil {
+ if err := p.transport.OnProxyConnectResponse(request.Context(), proxyURL, connectRequest, connectResponse); err != nil {
+ return nil, errors.New("connect webhook proxy failed")
+ }
+ }
+ if connectResponse.StatusCode != http.StatusOK {
+ return nil, errors.New("connect webhook proxy failed")
+ }
+ _ = connectResponse.Body.Close()
+
+ wireRequest := request.Clone(request.Context())
+ wireRequest.URL = cloneURL(request.URL)
+ wireRequest.Host = p.originalHost
+ wireRequest.Close = true
+ wireRequest.Header = request.Header.Clone()
+ if request.GetBody != nil {
+ body, bodyErr := request.GetBody()
+ if bodyErr != nil {
+ return nil, errors.New("prepare webhook proxy request failed")
+ }
+ wireRequest.Body = body
+ defer body.Close()
+ }
+ if err := wireRequest.Write(connection); err != nil {
+ return nil, errors.New("send webhook proxy request failed")
+ }
+ response, err := http.ReadResponse(reader, request)
+ if err != nil {
+ return nil, errors.New("read webhook proxy response failed")
+ }
+ response.Body = &proxyConnectionBody{ReadCloser: response.Body, connection: connection}
+ closeConnection = false
+ return response, nil
+}
+
+func (p *pinnedHTTPProxyRoundTripper) addProxyHeaders(request *http.Request, proxyURL *url.URL) error {
+ for name, values := range p.transport.ProxyConnectHeader {
+ request.Header[name] = append([]string(nil), values...)
+ }
+ if p.transport.GetProxyConnectHeader != nil {
+ target := request.URL.Host
+ if target == "" {
+ target = request.Host
+ }
+ headers, err := p.transport.GetProxyConnectHeader(request.Context(), proxyURL, target)
+ if err != nil {
+ return errors.New("prepare webhook proxy request failed")
+ }
+ for name, values := range headers {
+ request.Header[name] = append([]string(nil), values...)
+ }
+ }
+ if proxyURL.User != nil && request.Header.Get("Proxy-Authorization") == "" {
+ password, _ := proxyURL.User.Password()
+ credential := base64.StdEncoding.EncodeToString([]byte(proxyURL.User.Username() + ":" + password))
+ request.Header.Set("Proxy-Authorization", "Basic "+credential)
+ }
+ return nil
+}
+
+func pinnedHTTPConnectAuthority(target *url.URL) (string, error) {
+ if target == nil || target.Hostname() == "" {
+ return "", errors.New("prepare webhook proxy request failed")
+ }
+ port := target.Port()
+ if port == "" {
+ port = "80"
+ }
+ return net.JoinHostPort(target.Hostname(), port), nil
+}
+
+func proxyDialAddress(proxyURL *url.URL) (string, error) {
+ if proxyURL == nil || (proxyURL.Scheme != "http" && proxyURL.Scheme != "https") || proxyURL.Hostname() == "" {
+ return "", errors.New("unsupported webhook proxy")
+ }
+ port := proxyURL.Port()
+ if port == "" {
+ if proxyURL.Scheme == "https" {
+ port = "443"
+ } else {
+ port = "80"
+ }
+ }
+ return net.JoinHostPort(proxyURL.Hostname(), port), nil
+}
+
+func cloneURL(source *url.URL) *url.URL {
+ if source == nil {
+ return &url.URL{}
+ }
+ cloned := *source
+ return &cloned
+}
+
+type proxyConnectionBody struct {
+ io.ReadCloser
+ connection net.Conn
+}
+
+func (b *proxyConnectionBody) Close() error {
+ bodyErr := b.ReadCloser.Close()
+ connectionErr := b.connection.Close()
+ if bodyErr != nil {
+ return bodyErr
+ }
+ return connectionErr
+}
+
+func captureResponse(body []byte, input Request, target preparedTarget) string {
+ response := strings.ToValidUTF8(string(body), "\uFFFD")
+ redactions := make([]string, 0, len(input.Headers)+8)
+ redactions = append(redactions, urlRedactionValues(input.URL)...)
+ redactions = append(redactions, urlRedactionValues(target.URL)...)
+ if target.OriginalURL != nil {
+ redactions = append(redactions, urlRedactionValues(target.OriginalURL.String())...)
+ }
+ for name, value := range input.Headers {
+ if value != "" {
+ redactions = append(redactions, value)
+ }
+ redactions = append(redactions, headerRedactionValues(name, value)...)
+ }
+ sort.SliceStable(redactions, func(i, j int) bool {
+ return len(redactions[i]) > len(redactions[j])
+ })
+ for _, value := range redactions {
+ if value != "" {
+ response = strings.ReplaceAll(response, value, "[REDACTED]")
+ }
+ }
+ return truncateUTF8(response, MaxCapturedResponseBytes)
+}
+
+func urlRedactionValues(rawURL string) []string {
+ trimmed := strings.TrimSpace(rawURL)
+ values := []string{trimmed}
+ parsed, err := url.Parse(trimmed)
+ if err != nil {
+ return values
+ }
+ values = append(values, parsed.String())
+ for _, segment := range strings.Split(strings.Trim(parsed.EscapedPath(), "/"), "/") {
+ if segment == "" {
+ continue
+ }
+ values = append(values, segment)
+ if decoded, err := url.PathUnescape(segment); err == nil && decoded != segment {
+ values = append(values, decoded)
+ }
+ }
+ for _, pair := range strings.Split(parsed.RawQuery, "&") {
+ if pair == "" {
+ continue
+ }
+ encodedName, encodedValue, found := strings.Cut(pair, "=")
+ if !found {
+ values = append(values, encodedName)
+ if decoded, err := url.QueryUnescape(encodedName); err == nil && decoded != encodedName {
+ values = append(values, decoded)
+ }
+ continue
+ }
+ if encodedValue == "" {
+ continue
+ }
+ values = append(values, encodedValue)
+ if decoded, err := url.QueryUnescape(encodedValue); err == nil && decoded != encodedValue {
+ values = append(values, decoded)
+ }
+ }
+ return values
+}
+
+func headerRedactionValues(name, value string) []string {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ return nil
+ }
+ var values []string
+ switch http.CanonicalHeaderKey(name) {
+ case "Authorization", "Proxy-Authorization":
+ if _, credential, found := strings.Cut(trimmed, " "); found {
+ credential = strings.TrimSpace(credential)
+ if credential != "" {
+ values = append(values, credential)
+ }
+ }
+ case "Cookie":
+ for _, cookie := range strings.Split(trimmed, ";") {
+ _, cookieValue, found := strings.Cut(cookie, "=")
+ if !found {
+ continue
+ }
+ cookieValue = strings.Trim(strings.TrimSpace(cookieValue), `"`)
+ if cookieValue != "" {
+ values = append(values, cookieValue)
+ }
+ }
+ }
+ return values
+}
+
+func truncateUTF8(value string, maxBytes int) string {
+ if len(value) <= maxBytes {
+ return value
+ }
+ value = value[:maxBytes]
+ for !utf8.ValidString(value) {
+ value = value[:len(value)-1]
+ }
+ return value
+}
+
+type preparedTarget struct {
+ URL string
+ HostHeader string
+ ServerName string
+ OriginalURL *url.URL
+ Addresses []net.IP
+}
+
+func prepareTarget(ctx context.Context, rawURL string, preset Preset, resolver IPResolver) (preparedTarget, error) {
+ parsed, err := url.Parse(strings.TrimSpace(rawURL))
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
+ return preparedTarget{}, errors.New("invalid URL")
+ }
+ if parsed.Scheme != "http" && parsed.Scheme != "https" {
+ return preparedTarget{}, errors.New("unsupported URL scheme")
+ }
+ if preset == PresetDiscord {
+ query := parsed.Query()
+ query.Set("wait", "true")
+ parsed.RawQuery = query.Encode()
+ }
+
+ hostname := strings.TrimSuffix(parsed.Hostname(), ".")
+ if hostname == "" || strings.Contains(hostname, "%") {
+ return preparedTarget{}, errors.New("invalid URL host")
+ }
+ addresses, err := resolveAddresses(ctx, hostname, resolver)
+ if err != nil || len(addresses) == 0 {
+ return preparedTarget{}, errors.New("resolve webhook URL failed")
+ }
+ for _, address := range addresses {
+ if !publicWebhookIP(address) {
+ return preparedTarget{}, errors.New("webhook URL resolved to a blocked address")
+ }
+ }
+
+ originalHost := parsed.Host
+ originalURL := *parsed
+ parsed.Host = pinnedHost(addresses[0], parsed.Port())
+ return preparedTarget{
+ URL: parsed.String(),
+ HostHeader: originalHost,
+ ServerName: hostname,
+ OriginalURL: &originalURL,
+ Addresses: addresses,
+ }, nil
+}
+
+func resolveAddresses(ctx context.Context, hostname string, resolver IPResolver) ([]net.IP, error) {
+ if literal := net.ParseIP(hostname); literal != nil {
+ return []net.IP{literal}, nil
+ }
+ if resolver == nil {
+ resolver = net.DefaultResolver
+ }
+ resolved, err := resolver.LookupIPAddr(ctx, hostname)
+ if err != nil {
+ return nil, err
+ }
+ addresses := make([]net.IP, 0, len(resolved))
+ for _, item := range resolved {
+ if item.IP != nil {
+ addresses = append(addresses, item.IP)
+ }
+ }
+ return addresses, nil
+}
+
+func publicWebhookIP(ip net.IP) bool {
+ if ip == nil {
+ return false
+ }
+ if ipv4 := ip.To4(); ipv4 != nil {
+ ip = ipv4
+ }
+ if !ip.IsGlobalUnicast() || ip.IsUnspecified() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
+ ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() || ip.IsMulticast() {
+ return false
+ }
+ address, ok := netip.AddrFromSlice(ip)
+ if !ok {
+ return false
+ }
+ address = address.Unmap()
+ if wellKnownNAT64Prefix.Contains(address) {
+ value := address.As16()
+ return publicWebhookIP(net.IPv4(value[12], value[13], value[14], value[15]))
+ }
+ for _, prefix := range globallyReachableSpecialPrefixes {
+ if prefix.Contains(address) {
+ return true
+ }
+ }
+ for _, prefix := range blockedWebhookPrefixes {
+ if prefix.Contains(address) {
+ return false
+ }
+ }
+ return true
+}
+
+var wellKnownNAT64Prefix = netip.MustParsePrefix("64:ff9b::/96")
+
+var globallyReachableSpecialPrefixes = []netip.Prefix{
+ netip.MustParsePrefix("192.0.0.9/32"),
+ netip.MustParsePrefix("192.0.0.10/32"),
+ netip.MustParsePrefix("2001:1::1/128"),
+ netip.MustParsePrefix("2001:1::2/128"),
+ netip.MustParsePrefix("2001:1::3/128"),
+ netip.MustParsePrefix("2001:3::/32"),
+ netip.MustParsePrefix("2001:4:112::/48"),
+ netip.MustParsePrefix("2001:20::/28"),
+ netip.MustParsePrefix("2001:30::/28"),
+}
+
+var blockedWebhookPrefixes = []netip.Prefix{
+ netip.MustParsePrefix("0.0.0.0/8"),
+ netip.MustParsePrefix("100.64.0.0/10"),
+ netip.MustParsePrefix("192.0.0.0/24"),
+ netip.MustParsePrefix("192.0.2.0/24"),
+ netip.MustParsePrefix("192.88.99.0/24"),
+ netip.MustParsePrefix("198.18.0.0/15"),
+ netip.MustParsePrefix("198.51.100.0/24"),
+ netip.MustParsePrefix("203.0.113.0/24"),
+ netip.MustParsePrefix("240.0.0.0/4"),
+ netip.MustParsePrefix("64:ff9b:1::/48"),
+ netip.MustParsePrefix("100::/64"),
+ netip.MustParsePrefix("100:0:0:1::/64"),
+ netip.MustParsePrefix("2001::/23"),
+ netip.MustParsePrefix("2001:db8::/32"),
+ netip.MustParsePrefix("2002::/16"),
+ netip.MustParsePrefix("3fff::/20"),
+ netip.MustParsePrefix("5f00::/16"),
+ netip.MustParsePrefix("fec0::/10"),
+}
+
+func pinnedHost(ip net.IP, port string) string {
+ host := ip.String()
+ if port != "" {
+ return net.JoinHostPort(host, port)
+ }
+ if ip.To4() == nil {
+ return "[" + host + "]"
+ }
+ return host
+}
+
+func validateRenderedBody(format BodyFormat, body []byte) (string, error) {
+ if format == "" {
+ format = BodyJSON
+ }
+ switch format {
+ case BodyJSON:
+ if !json.Valid(body) {
+ return "", errors.New("rendered webhook JSON body is invalid")
+ }
+ return "application/json; charset=utf-8", nil
+ case BodyForm:
+ return "application/x-www-form-urlencoded", nil
+ case BodyText:
+ return "text/plain; charset=utf-8", nil
+ default:
+ return "", errors.New("unsupported webhook body format")
+ }
+}
+
+func validateHeaders(headers map[string]string) error {
+ for name, value := range headers {
+ if !validHeaderName(name) || strings.ContainsAny(value, "\r\n") || reservedHeader(name) {
+ return errors.New("invalid webhook request header")
+ }
+ }
+ return nil
+}
+
+func validHeaderName(name string) bool {
+ if name == "" {
+ return false
+ }
+ for i := 0; i < len(name); i++ {
+ if !headerTokenByte(name[i]) {
+ return false
+ }
+ }
+ return true
+}
+
+func headerTokenByte(b byte) bool {
+ if b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' {
+ return true
+ }
+ return strings.ContainsRune("!#$%&'*+-.^_`|~", rune(b))
+}
+
+func reservedHeader(name string) bool {
+ switch http.CanonicalHeaderKey(name) {
+ case "Connection", "Content-Length", "Content-Type", "Host", "Proxy-Authorization", "Proxy-Connection", "Te", "Trailer", "Transfer-Encoding", "Upgrade":
+ return true
+ default:
+ return false
+ }
+}
+
+func systemTLSTransport(input *http.Transport, serverName string, originalURL *url.URL, addresses []net.IP) *http.Transport {
+ var transport *http.Transport
+ if input != nil {
+ transport = input.Clone()
+ } else if defaultTransport, ok := http.DefaultTransport.(*http.Transport); ok {
+ transport = defaultTransport.Clone()
+ } else {
+ transport = &http.Transport{Proxy: http.ProxyFromEnvironment}
+ }
+
+ tlsConfig := &tls.Config{}
+ if transport.TLSClientConfig != nil {
+ tlsConfig = transport.TLSClientConfig.Clone()
+ }
+ tlsConfig.InsecureSkipVerify = false
+ if roots, err := loadWebhookSystemCertPool(); err == nil {
+ tlsConfig.RootCAs = roots
+ } else {
+ tlsConfig.RootCAs = nil
+ }
+ tlsConfig.Certificates = nil
+ tlsConfig.GetClientCertificate = nil
+ tlsConfig.ServerName = serverName
+ transport.TLSClientConfig = tlsConfig
+ transport.DialTLS = nil
+ transport.DialTLSContext = nil
+ if transport.Proxy != nil && originalURL != nil {
+ proxySelector := transport.Proxy
+ selectionURL := *originalURL
+ transport.Proxy = func(request *http.Request) (*url.URL, error) {
+ selectionRequest := request.Clone(request.Context())
+ selectionRequest.URL = &selectionURL
+ selectionRequest.Host = selectionURL.Host
+ return proxySelector(selectionRequest)
+ }
+ }
+ if len(addresses) > 1 {
+ firstAddress := addresses[0]
+ baseDial := transport.DialContext
+ if baseDial == nil {
+ baseDial = (&net.Dialer{}).DialContext
+ }
+ transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
+ host, port, err := net.SplitHostPort(address)
+ dialIP := net.ParseIP(host)
+ if err != nil || dialIP == nil || !dialIP.Equal(firstAddress) {
+ return baseDial(ctx, network, address)
+ }
+ var lastErr error
+ for _, candidate := range addresses {
+ connection, dialErr := baseDial(ctx, network, net.JoinHostPort(candidate.String(), port))
+ if dialErr == nil {
+ return connection, nil
+ }
+ lastErr = dialErr
+ }
+ return nil, lastErr
+ }
+ }
+ secureDial := transport.DialContext
+ if secureDial == nil {
+ secureDial = (&net.Dialer{}).DialContext
+ }
+ targetPort := "443"
+ if originalURL != nil && originalURL.Port() != "" {
+ targetPort = originalURL.Port()
+ }
+ transport.DialTLSContext = func(ctx context.Context, network, address string) (net.Conn, error) {
+ connection, err := secureDial(ctx, network, address)
+ if err != nil {
+ return nil, err
+ }
+ host, port, splitErr := net.SplitHostPort(address)
+ if splitErr != nil {
+ _ = connection.Close()
+ return nil, splitErr
+ }
+ firstHopConfig := tlsConfig.Clone()
+ if port == targetPort && isPinnedWebhookAddress(host, addresses) {
+ firstHopConfig.ServerName = serverName
+ } else {
+ firstHopConfig.ServerName = strings.TrimSuffix(host, ".")
+ firstHopConfig.NextProtos = []string{"http/1.1"}
+ }
+ tlsConnection := tls.Client(connection, firstHopConfig)
+ if err := tlsConnection.HandshakeContext(ctx); err != nil {
+ _ = connection.Close()
+ return nil, err
+ }
+ return tlsConnection, nil
+ }
+ return transport
+}
+
+func isPinnedWebhookAddress(host string, addresses []net.IP) bool {
+ dialIP := net.ParseIP(host)
+ if dialIP == nil {
+ return false
+ }
+ for _, address := range addresses {
+ if dialIP.Equal(address) {
+ return true
+ }
+ }
+ return false
+}
+
+func validateResponse(preset Preset, statusCode int, body []byte) error {
+ if preset == "" {
+ preset = PresetGeneric
+ }
+ switch preset {
+ case PresetSlack:
+ if statusCode != http.StatusOK || strings.TrimSpace(string(body)) != "ok" {
+ return errors.New("webhook provider rejected response")
+ }
+ return nil
+ case PresetGeneric, PresetDiscord, PresetTeams:
+ if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
+ return errors.New("webhook provider rejected response")
+ }
+ return nil
+ default:
+ return errors.New("unsupported webhook preset")
+ }
+}
diff --git a/agent/utils/webhook_sender/webhook_sender.go b/agent/utils/webhook_sender/webhook_sender.go
new file mode 100644
index 000000000..b9d7cd1ba
--- /dev/null
+++ b/agent/utils/webhook_sender/webhook_sender.go
@@ -0,0 +1,130 @@
+package webhook_sender
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/1Panel-dev/1Panel/agent/constant"
+)
+
+func NormalizeToText(s string) string {
+ r := strings.NewReplacer(
+ "\r\n", "\n",
+ "\r", "\n",
+ "
", "\n",
+ "
", "\n",
+ "
", "\n",
+ "
", "\n",
+ "", "",
+ "", "\n",
+ "
", "",
+ " ", " ",
+ )
+ return r.Replace(s)
+}
+
+func BuildWebhookPayload(method string, title string, text string) (any, error) {
+ switch method {
+ case constant.WeCom:
+ return map[string]any{
+ "msgtype": "markdown",
+ "markdown": map[string]any{
+ "content": fmt.Sprintf("**%s**\n%s", title, text),
+ },
+ }, nil
+ case constant.DingTalk:
+ return map[string]any{
+ "msgtype": "text",
+ "text": map[string]any{
+ "content": fmt.Sprintf("%s\n%s", title, text),
+ },
+ }, nil
+ case constant.FeiShu:
+ return map[string]any{
+ "msg_type": "text",
+ "content": map[string]any{
+ "text": fmt.Sprintf("%s\n%s", title, text),
+ },
+ }, nil
+ case constant.Custom:
+ return map[string]any{
+ "title": title,
+ "message": text,
+ "type": "1panel_alert",
+ "ts": time.Now().Unix(),
+ }, nil
+ default:
+ return nil, fmt.Errorf("unsupported webhook method: %s", method)
+ }
+}
+
+func SendWebhookRequest(method string, url string, payload any, transport *http.Transport) error {
+ return sendLegacyWebhookRequest(method, url, payload, transport)
+}
+
+func sendLegacyWebhookRequest(method string, url string, payload any, transport *http.Transport) error {
+ if url == "" {
+ return fmt.Errorf("send webhook request failed: url is empty")
+ }
+ if payload == nil {
+ return fmt.Errorf("send webhook request failed: payload is nil")
+ }
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("marshal webhook payload failed")
+ }
+ client := &http.Client{Timeout: RequestTimeout}
+ if transport != nil {
+ client.Transport = transport
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), RequestTimeout)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
+ if err != nil {
+ return fmt.Errorf("create webhook request failed")
+ }
+ req.Header.Set("Content-Type", "application/json; charset=utf-8")
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("webhook request failed")
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
+ return fmt.Errorf("webhook response status code: %d", resp.StatusCode)
+ }
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("read webhook response failed")
+ }
+ switch method {
+ case constant.WeCom, constant.DingTalk:
+ var result struct {
+ Errcode int `json:"errcode"`
+ Errmsg string `json:"errmsg"`
+ }
+ if err := json.Unmarshal(respBody, &result); err != nil {
+ return nil
+ }
+ if result.Errcode != 0 {
+ return fmt.Errorf("webhook provider rejected response")
+ }
+ case constant.FeiShu:
+ var result struct {
+ StatusCode int `json:"StatusCode"`
+ Code int `json:"code"`
+ }
+ if err := json.Unmarshal(respBody, &result); err != nil {
+ return nil
+ }
+ if result.StatusCode != 0 || result.Code != 0 {
+ return fmt.Errorf("webhook provider rejected response")
+ }
+ }
+ return nil
+}
diff --git a/agent/utils/xpack/alert_delivery.go b/agent/utils/xpack/alert_delivery.go
new file mode 100644
index 000000000..bf6c13f62
--- /dev/null
+++ b/agent/utils/xpack/alert_delivery.go
@@ -0,0 +1,57 @@
+package xpack
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/1Panel-dev/1Panel/agent/app/dto"
+ "github.com/1Panel-dev/1Panel/agent/app/model"
+ "github.com/1Panel-dev/1Panel/agent/utils/xpack/providers"
+)
+
+func DeliverCustomWebhookAlertLog(
+ alertType string,
+ info dto.AlertDTO,
+ create dto.AlertLogCreate,
+ project string,
+ params []dto.Param,
+ config model.AlertConfig,
+ transport *http.Transport,
+ agentInfo *dto.AgentInfo,
+ task dto.AlertTaskMetadata,
+) (providers.DeliveryResult, error) {
+ if provider, ok := AlertProvider.(providers.CustomWebhookDeliveryProvider); ok {
+ result, err := provider.CreateCustomWebhookAlertLog(alertType, info, create, project, params, config, transport, agentInfo, task)
+ return validateCustomWebhookDeliveryResult(result, err)
+ }
+ err := AlertProvider.CreateWebhookAlertLog(alertType, info, create, project, params, config, transport, agentInfo)
+ return providers.DeliveryResult{}, err
+}
+
+func DeliverTaskScanCustomWebhookAlertLog(
+ alert dto.AlertDTO,
+ alertType string,
+ create dto.AlertLogCreate,
+ pushAlert dto.PushAlert,
+ config model.AlertConfig,
+ transport *http.Transport,
+ agentInfo *dto.AgentInfo,
+ task dto.AlertTaskMetadata,
+) (providers.DeliveryResult, error) {
+ if provider, ok := AlertProvider.(providers.CustomWebhookDeliveryProvider); ok {
+ result, err := provider.CreateTaskScanCustomWebhookAlertLog(alert, alertType, create, pushAlert, config, transport, agentInfo, task)
+ return validateCustomWebhookDeliveryResult(result, err)
+ }
+ err := AlertProvider.CreateTaskScanWebhookAlertLog(alert, alertType, create, pushAlert, config, transport, agentInfo)
+ return providers.DeliveryResult{}, err
+}
+
+func validateCustomWebhookDeliveryResult(result providers.DeliveryResult, err error) (providers.DeliveryResult, error) {
+ if err != nil {
+ return result, err
+ }
+ if result.Queued && result.LogID == 0 {
+ return providers.DeliveryResult{}, fmt.Errorf("custom webhook provider queued a delivery without a log ID")
+ }
+ return result, nil
+}
diff --git a/agent/utils/xpack/helper/alert.go b/agent/utils/xpack/helper/alert.go
index b35693c80..5e7d72e7b 100644
--- a/agent/utils/xpack/helper/alert.go
+++ b/agent/utils/xpack/helper/alert.go
@@ -5,11 +5,24 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
+ "github.com/1Panel-dev/1Panel/agent/constant"
+ alertUtil "github.com/1Panel-dev/1Panel/agent/utils/alert"
"github.com/1Panel-dev/1Panel/agent/utils/xpack/providers"
)
type alertHelper struct{}
+var (
+ _ providers.CustomWebhookTester = (*alertHelper)(nil)
+ _ providers.CustomWebhookDeliveryProvider = (*alertHelper)(nil)
+)
+
+var loadCommunityCustomWebhookContext = func() (*http.Transport, *dto.AgentInfo) {
+ multiNode := &multiNodeHelper{}
+ agentInfo, _ := multiNode.GetAgentInfo()
+ return multiNode.LoadRequestTransport(), agentInfo
+}
+
func NewIAlertProvider() providers.AlertProvider {
return &alertHelper{}
}
@@ -23,13 +36,34 @@ func (a *alertHelper) CreateSMSAlertLog(alertType string, info dto.AlertDTO, cre
}
func (a *alertHelper) CreateTaskScanWebhookAlertLog(alert dto.AlertDTO, alertType string, create dto.AlertLogCreate, pushAlert dto.PushAlert, config model.AlertConfig, transport *http.Transport, agentInfo *dto.AgentInfo) error {
+ if config.Type == constant.Custom {
+ return alertUtil.CreateTaskScanCustomWebhookAlertLog(alert, alertType, create, pushAlert, config, transport, agentInfo)
+ }
return nil
}
func (a *alertHelper) CreateWebhookAlertLog(alertType string, info dto.AlertDTO, create dto.AlertLogCreate, project string, params []dto.Param, config model.AlertConfig, transport *http.Transport, agentInfo *dto.AgentInfo) error {
+ if config.Type == constant.Custom {
+ return alertUtil.CreateCustomWebhookAlertLog(alertType, info, create, project, params, config, transport, agentInfo)
+ }
return nil
}
+func (a *alertHelper) CreateCustomWebhookAlertLog(alertType string, info dto.AlertDTO, create dto.AlertLogCreate, project string, params []dto.Param, config model.AlertConfig, transport *http.Transport, agentInfo *dto.AgentInfo, _ dto.AlertTaskMetadata) (providers.DeliveryResult, error) {
+ err := alertUtil.CreateCustomWebhookAlertLog(alertType, info, create, project, params, config, transport, agentInfo)
+ return providers.DeliveryResult{}, err
+}
+
+func (a *alertHelper) CreateTaskScanCustomWebhookAlertLog(alert dto.AlertDTO, alertType string, create dto.AlertLogCreate, pushAlert dto.PushAlert, config model.AlertConfig, transport *http.Transport, agentInfo *dto.AgentInfo, _ dto.AlertTaskMetadata) (providers.DeliveryResult, error) {
+ err := alertUtil.CreateTaskScanCustomWebhookAlertLog(alert, alertType, create, pushAlert, config, transport, agentInfo)
+ return providers.DeliveryResult{}, err
+}
+
+func (a *alertHelper) TestCustomWebhook(config dto.AlertCustomWebhookResolvedConfig) (dto.AlertConfigTestResult, error) {
+ transport, agentInfo := loadCommunityCustomWebhookContext()
+ return alertUtil.TestCustomWebhook(config, transport, agentInfo)
+}
+
func (a *alertHelper) GetLicenseErrorAlert() (uint, error) {
return 0, nil
}
diff --git a/agent/utils/xpack/providers/alert.go b/agent/utils/xpack/providers/alert.go
index 8f885866d..ed9a19246 100644
--- a/agent/utils/xpack/providers/alert.go
+++ b/agent/utils/xpack/providers/alert.go
@@ -1,12 +1,15 @@
package providers
import (
+ "errors"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
)
+var ErrCustomWebhookUnsupported = errors.New("custom webhook sender is unavailable")
+
type AlertProvider interface {
GetNodeErrorAlert() (uint, error)
GetLicenseErrorAlert() (uint, error)
@@ -16,3 +19,17 @@ type AlertProvider interface {
CreateTaskScanWebhookAlertLog(alert dto.AlertDTO, alertType string, create dto.AlertLogCreate, pushAlert dto.PushAlert, config model.AlertConfig, transport *http.Transport, agentInfo *dto.AgentInfo) error
CreateWebhookAlertLog(alertType string, info dto.AlertDTO, create dto.AlertLogCreate, project string, params []dto.Param, config model.AlertConfig, transport *http.Transport, agentInfo *dto.AgentInfo) error
}
+
+type CustomWebhookTester interface {
+ TestCustomWebhook(config dto.AlertCustomWebhookResolvedConfig) (dto.AlertConfigTestResult, error)
+}
+
+type DeliveryResult struct {
+ Queued bool
+ LogID uint
+}
+
+type CustomWebhookDeliveryProvider interface {
+ CreateCustomWebhookAlertLog(alertType string, info dto.AlertDTO, create dto.AlertLogCreate, project string, params []dto.Param, config model.AlertConfig, transport *http.Transport, agentInfo *dto.AgentInfo, task dto.AlertTaskMetadata) (DeliveryResult, error)
+ CreateTaskScanCustomWebhookAlertLog(alert dto.AlertDTO, alertType string, create dto.AlertLogCreate, pushAlert dto.PushAlert, config model.AlertConfig, transport *http.Transport, agentInfo *dto.AgentInfo, task dto.AlertTaskMetadata) (DeliveryResult, error)
+}
diff --git a/core/app/model/alert.go b/core/app/model/alert.go
index 2043cb521..cbdffc575 100644
--- a/core/app/model/alert.go
+++ b/core/app/model/alert.go
@@ -2,8 +2,10 @@ package model
type AlertConfig struct {
BaseModel
- Type string `json:"type"`
- Title string `json:"title"`
- Status string `json:"status"`
- Config string `json:"config"`
+ UID string `json:"uid"`
+ Type string `json:"type"`
+ Title string `json:"title"`
+ Status string `json:"status"`
+ Config string `json:"config"`
+ SecretConfig string `json:"-"`
}
diff --git a/core/cmd/server/docs/x-log.json b/core/cmd/server/docs/x-log.json
index 2a27c52c6..b3ac1bc15 100644
--- a/core/cmd/server/docs/x-log.json
+++ b/core/cmd/server/docs/x-log.json
@@ -5787,4 +5787,4 @@
"formatZH": "从主节点同步设置",
"formatEN": "sync settings from master"
}
-}
\ No newline at end of file
+}
diff --git a/core/init/router/proxy.go b/core/init/router/proxy.go
index 3be526474..1564f3e83 100644
--- a/core/init/router/proxy.go
+++ b/core/init/router/proxy.go
@@ -1,8 +1,10 @@
package router
import (
+ "errors"
"net/http"
"net/url"
+ "path"
"strconv"
"strings"
@@ -18,6 +20,8 @@ import (
"github.com/gin-gonic/gin"
)
+var errInternalOnlyAgentEndpoint = errors.New("internal agent endpoint cannot be proxied")
+
func Proxy() gin.HandlerFunc {
return func(c *gin.Context) {
reqPath := c.Request.URL.Path
@@ -51,6 +55,11 @@ func Proxy() gin.HandlerFunc {
c.Request.Header.Set("X-Panel-User", url.QueryEscape(userName))
}
+ if isInternalOnlyAgentEndpoint(reqPath) {
+ helper.ErrorWithDetail(c, http.StatusForbidden, "ErrProxy", errInternalOnlyAgentEndpoint)
+ return
+ }
+
if reqPath == "/api/v2/hosts/terminal/local" && (currentNode == "local" || len(currentNode) == 0) {
proxyLocalAgent(c)
return
@@ -65,6 +74,12 @@ func Proxy() gin.HandlerFunc {
}
}
+func isInternalOnlyAgentEndpoint(reqPath string) bool {
+ normalizedPath := path.Clean(reqPath)
+ return normalizedPath == "/api/v2/xpack/alert/offline/email" ||
+ normalizedPath == "/api/v2/xpack/alert/offline/webhook"
+}
+
func proxyLocalAgent(c *gin.Context) {
defer func() {
if err := recover(); err != nil && err != http.ErrAbortHandler {
diff --git a/core/utils/alert_webhook/secret.go b/core/utils/alert_webhook/secret.go
new file mode 100644
index 000000000..edb849d0d
--- /dev/null
+++ b/core/utils/alert_webhook/secret.go
@@ -0,0 +1,135 @@
+package alert_webhook
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/1Panel-dev/1Panel/core/app/model"
+ "github.com/1Panel-dev/1Panel/core/constant"
+ "github.com/1Panel-dev/1Panel/core/global"
+ "github.com/1Panel-dev/1Panel/core/utils/encrypt"
+ "github.com/google/uuid"
+)
+
+const (
+ coreKeyPrefix = "core:v1:"
+ secretSchemaV1 = 1
+ maxSecretLength = 256 * 1024
+ maxSecretHeaders = 64
+ maxSecretValueSize = 16 * 1024
+)
+
+type secretConfig struct {
+ SchemaVersion int `json:"schemaVersion"`
+ URL string `json:"url"`
+ Headers map[string]string `json:"headers,omitempty"`
+}
+
+func ExportSecretPlain(config model.AlertConfig) (string, error) {
+ if config.Type != constant.Custom {
+ return "", fmt.Errorf("alert config %d is not a custom webhook", config.ID)
+ }
+ if !strings.HasPrefix(config.SecretConfig, coreKeyPrefix) {
+ return "", fmt.Errorf("custom webhook secret is not encrypted with the core key")
+ }
+ payload := strings.TrimPrefix(config.SecretConfig, coreKeyPrefix)
+ if payload == "" {
+ return "", fmt.Errorf("custom webhook secret ciphertext is empty")
+ }
+ key, err := loadEncryptKey()
+ if err != nil {
+ return "", err
+ }
+ plainText, err := encrypt.StringDecryptWithKey(payload, key)
+ if err != nil {
+ return "", fmt.Errorf("decrypt custom webhook secret: %w", err)
+ }
+ secret, err := decodeAndValidateSecret(plainText)
+ if err != nil {
+ return "", err
+ }
+ canonical, err := json.Marshal(secret)
+ if err != nil {
+ return "", fmt.Errorf("encode custom webhook secret: %w", err)
+ }
+ return string(canonical), nil
+}
+
+func loadEncryptKey() (string, error) {
+ if key := strings.TrimSpace(global.CONF.Base.EncryptKey); key != "" {
+ return key, nil
+ }
+ if global.DB != nil {
+ var setting model.Setting
+ if err := global.DB.Where("key = ?", "EncryptKey").First(&setting).Error; err == nil {
+ if key := strings.TrimSpace(setting.Value); key != "" {
+ return key, nil
+ }
+ }
+ }
+ return "", fmt.Errorf("custom webhook core encrypt key is empty")
+}
+
+func decodeAndValidateSecret(plainText string) (secretConfig, error) {
+ if len(plainText) == 0 || len(plainText) > maxSecretLength {
+ return secretConfig{}, fmt.Errorf("custom webhook secret has an invalid size")
+ }
+ var secret secretConfig
+ decoder := json.NewDecoder(strings.NewReader(plainText))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&secret); err != nil {
+ return secretConfig{}, fmt.Errorf("decode custom webhook secret: %w", err)
+ }
+ if err := decoder.Decode(&struct{}{}); err != io.EOF {
+ return secretConfig{}, fmt.Errorf("decode custom webhook secret: multiple JSON values are not allowed")
+ }
+ if secret.SchemaVersion != secretSchemaV1 {
+ return secretConfig{}, fmt.Errorf("unsupported custom webhook secret schemaVersion: %d", secret.SchemaVersion)
+ }
+ if err := validateSecretURL(secret.URL); err != nil {
+ return secretConfig{}, err
+ }
+ if len(secret.Headers) > maxSecretHeaders {
+ return secretConfig{}, fmt.Errorf("custom webhook secret headers exceed the limit")
+ }
+ if secret.Headers == nil {
+ secret.Headers = map[string]string{}
+ }
+ for uid, value := range secret.Headers {
+ if _, err := uuid.Parse(strings.TrimSpace(uid)); err != nil {
+ return secretConfig{}, fmt.Errorf("custom webhook secret header uid must be a UUID")
+ }
+ if value == "" || len(value) > maxSecretValueSize || strings.ContainsAny(value, "\r\n") {
+ return secretConfig{}, fmt.Errorf("custom webhook secret header is invalid")
+ }
+ }
+ return secret, nil
+}
+
+func validateSecretURL(rawURL string) error {
+ if rawURL == "" {
+ return nil
+ }
+ parsed, err := url.Parse(rawURL)
+ if err != nil {
+ return fmt.Errorf("invalid custom webhook URL")
+ }
+ hostname := strings.TrimSuffix(strings.TrimSpace(parsed.Hostname()), ".")
+ if (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || hostname == "" || strings.Contains(hostname, "%") || parsed.User != nil || parsed.Fragment != "" {
+ return fmt.Errorf("invalid custom webhook URL")
+ }
+ if strings.HasSuffix(parsed.Host, ":") {
+ return fmt.Errorf("invalid custom webhook URL")
+ }
+ if port := parsed.Port(); port != "" {
+ value, err := strconv.Atoi(port)
+ if err != nil || value < 1 || value > 65535 {
+ return fmt.Errorf("invalid custom webhook URL")
+ }
+ }
+ return nil
+}
diff --git a/core/utils/req_helper/proxy_local/req_to_local.go b/core/utils/req_helper/proxy_local/req_to_local.go
index df202ce92..1e65f6e90 100644
--- a/core/utils/req_helper/proxy_local/req_to_local.go
+++ b/core/utils/req_helper/proxy_local/req_to_local.go
@@ -11,6 +11,7 @@ import (
"net/url"
"os"
"strings"
+ "time"
"github.com/gin-gonic/gin"
@@ -24,6 +25,12 @@ func NewLocalClient(reqUrl, reqMethod string, body io.Reader, ctx *gin.Context)
return client.Request(reqUrl, reqMethod, body, ctx)
}
+func NewLocalClientWithContext(requestContext context.Context, reqURL, reqMethod string, body io.Reader, ctx *gin.Context, timeout time.Duration) (interface{}, error) {
+ client := newReusableClientWithTimeout("/etc/1panel/agent.sock", timeout)
+ defer client.CloseIdleConnections()
+ return client.RequestWithContext(requestContext, reqURL, reqMethod, body, ctx)
+}
+
type ReusableClient struct {
client *http.Client
sockPath string
@@ -34,17 +41,19 @@ func NewReusableClient() *ReusableClient {
}
func newReusableClient(sockPath string) *ReusableClient {
- dialUnix := func() (conn net.Conn, err error) {
- return net.Dial("unix", sockPath)
- }
+ return newReusableClientWithTimeout(sockPath, 0)
+}
+
+func newReusableClientWithTimeout(sockPath string, timeout time.Duration) *ReusableClient {
+ dialer := &net.Dialer{Timeout: timeout}
transport := &http.Transport{
MaxIdleConns: 12,
MaxIdleConnsPerHost: 6,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
- return dialUnix()
+ return dialer.DialContext(ctx, "unix", sockPath)
},
}
- return &ReusableClient{client: &http.Client{Transport: transport}, sockPath: sockPath}
+ return &ReusableClient{client: &http.Client{Transport: transport, Timeout: timeout}, sockPath: sockPath}
}
func (c *ReusableClient) CloseIdleConnections() {
@@ -55,6 +64,10 @@ func (c *ReusableClient) CloseIdleConnections() {
}
func (c *ReusableClient) Request(reqUrl, reqMethod string, body io.Reader, ctx *gin.Context) (interface{}, error) {
+ return c.RequestWithContext(context.Background(), reqUrl, reqMethod, body, ctx)
+}
+
+func (c *ReusableClient) RequestWithContext(requestContext context.Context, reqUrl, reqMethod string, body io.Reader, ctx *gin.Context) (interface{}, error) {
if c == nil || c.client == nil {
return nil, errors.New("local agent client is not initialized")
}
@@ -71,7 +84,7 @@ func (c *ReusableClient) Request(reqUrl, reqMethod string, body io.Reader, ctx *
Host: parsedURL.Host,
}
- req, err := http.NewRequest(reqMethod, rURL.String(), body)
+ req, err := http.NewRequestWithContext(requestContext, reqMethod, rURL.String(), body)
if err != nil {
return nil, fmt.Errorf("creating request failed, err: %v", err)
}
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
index 449f53dba..6329d1d36 100644
--- a/frontend/src/api/index.ts
+++ b/frontend/src/api/index.ts
@@ -206,7 +206,7 @@ class RequestHttp {
return this.service.delete(url, { params, ..._object });
}
download
(url: string, params?: object, _object = {}): Promise {
- return this.service.post(url, params, _object);
+ return this.service.post(url, params, _object) as unknown as Promise;
}
upload(url: string, params: object = {}, config?: RequestConfig): Promise> {
return this.service.post(url, params, config);
diff --git a/frontend/src/api/interface/alert.ts b/frontend/src/api/interface/alert.ts
index 4d1535895..77d5bf5d9 100644
--- a/frontend/src/api/interface/alert.ts
+++ b/frontend/src/api/interface/alert.ts
@@ -156,6 +156,7 @@ export namespace Alert {
export interface AlertConfigUpdateReq {
id: number;
+ revision?: string;
type: string;
title: string;
config: string;
@@ -163,9 +164,17 @@ export namespace Alert {
displayName: string;
}
+ export interface AlertConfigStatusReq {
+ id: number;
+ status: string;
+ }
+
export interface AlertConfigTest {
- port: number;
+ id?: number;
+ type: 'email';
+ config: string;
host: string;
+ port: number;
sender: string;
userName: string;
password: string;
@@ -174,6 +183,20 @@ export namespace Alert {
recipient: string;
}
+ export interface AlertConfigCustomTest {
+ id?: number;
+ type: 'custom';
+ config: string;
+ }
+
+ export interface AlertConfigCustomTestResult {
+ success: boolean;
+ statusCode?: number;
+ duration?: number;
+ message?: string;
+ response?: string;
+ }
+
export interface CommonAlertConfig {
id?: number;
type: string;
diff --git a/frontend/src/api/modules/alert.ts b/frontend/src/api/modules/alert.ts
index 78f60dfc5..4242e7e18 100644
--- a/frontend/src/api/modules/alert.ts
+++ b/frontend/src/api/modules/alert.ts
@@ -14,6 +14,7 @@ const resolveAlertConfigExcludeTypes = (excludeTypes: string[] = []) => {
}
return Array.from(types);
};
+
export const SearchAlerts = (req: Alert.AlertSearch, currentNode?: string) => {
return http.post>(
`/alert/search`,
@@ -99,10 +100,18 @@ export const UpdateAlertConfig = (req: Alert.AlertConfigUpdateReq) => {
return http.post(`/alert/config/update`, req);
};
+export const UpdateAlertConfigStatus = (req: Alert.AlertConfigStatusReq) => {
+ return http.post(`/alert/config/status`, req);
+};
+
export const TestAlertConfig = (req: Alert.AlertConfigTest) => {
return http.post(`/alert/config/test`, req);
};
+export const TestCustomAlertConfig = (req: Alert.AlertConfigCustomTest) => {
+ return http.post(`/alert/config/test`, req);
+};
+
export const SyncAlertInfo = (req: Alert.AlertLogId) => {
return http.post(`/xpack/alert/logs/sync`, req);
};
diff --git a/frontend/src/components/app-status/index.vue b/frontend/src/components/app-status/index.vue
index b5f29e4ad..8adb41ada 100644
--- a/frontend/src/components/app-status/index.vue
+++ b/frontend/src/components/app-status/index.vue
@@ -199,6 +199,6 @@ defineExpose({
border: 1px solid var(--el-color-warning);
background-color: transparent;
padding: 8px 8px;
- width: 70px;
+ width: 71px;
}
diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts
index 2df1411ef..7ce2b80d7 100644
--- a/frontend/src/lang/modules/en.ts
+++ b/frontend/src/lang/modules/en.ts
@@ -946,7 +946,6 @@ const message = {
from_remote: 'This model was not downloaded via 1Panel, no related pull logs.',
no_logs: 'The pull logs for this model have been deleted and cannot be viewed.',
vllmVersionHelper: 'For FusionXpark GB 10 servers, please select the -cu130 version.',
- ascendVisibleDevices: 'Ascend Visible Devices',
vllmCommandPortHelper:
'The startup command must use port {0}; otherwise, the service will be inaccessible.',
ascendVisibleDevices: 'Ascend visible devices (ASCEND_RT_VISIBLE_DEVICES)',
@@ -1140,7 +1139,6 @@ const message = {
cachedToken: 'Cached Tokens',
cacheHitRate: 'Cache Hit Rate',
activeUsers: 'Active Users',
- activeStreamingRequests: 'Active Streaming Requests',
activeModels: 'Active Models',
failedRequests: 'Failed Requests',
averageTokenPerRequest: 'Avg Tokens/Request',
@@ -6703,7 +6701,57 @@ const message = {
feiShuConfigHelper: 'Feishu alert notification configuration',
webhookName: 'Bot name',
webhookUrl: 'Webhook URL',
- alertConfigProHelper: 'Commercial Edition also supports WeCom, DingTalk, Feishu, and SMS alerts.',
+ custom: 'Webhook',
+ webhookPreset: 'Preset',
+ genericJsonPreset: 'Generic JSON',
+ customPreset: 'Custom',
+ webhookUrlSecretHelper: 'The Webhook URL is encrypted at rest and can be viewed and edited here',
+ webhookPublicAddressHelper:
+ 'Only publicly reachable HTTP/HTTPS addresses are supported; local, private, and reserved addresses are blocked',
+ customWebhookRecoveryRequired:
+ 'This configuration is invalid or from an older version. Re-enter the Webhook URL and any required secret headers, then save.',
+ clearSecret: 'Clear',
+ keepSecret: 'Keep unchanged',
+ secretCleared: 'This secret will be cleared when you save',
+ bodyType: 'Body Type',
+ bodyTemplate: 'Body Template',
+ formFieldName: 'Field name',
+ formFieldValue: 'Field value',
+ addFormField: 'Add Field',
+ webhookAdvanced: 'Advanced',
+ headers: 'Headers',
+ headerName: 'Header name',
+ headerValue: 'Header value',
+ secretValue: 'Secret value',
+ addHeader: 'Add Header',
+ templateVariables: 'Template Variables',
+ templateVariableTitle: 'Alert title',
+ templateVariableMessage: 'Alert content',
+ templateVariableType: 'Alert type',
+ templateVariableNodeName: 'Node name',
+ templateVariableTimestamp: 'Event time',
+ templateVariablesHelper:
+ 'title=alert title, message=alert content, type=alert type, nodeName=node name, timestamp=event time. Click a variable to insert it into the body.',
+ testResultStale: 'The configuration changed, so the previous test result is no longer valid',
+ alertConfigChanged: 'The configuration was updated. Refresh and try again.',
+ presetOverwriteHelper: 'Switching presets will replace the current body configuration. Continue?',
+ customWebhookValidation: {
+ displayNameRequired: 'Enter a display name',
+ urlRequired: 'Enter a Webhook URL',
+ urlInvalid: 'The Webhook URL must be a valid HTTP or HTTPS URL',
+ bodyRequired: 'Configure the request body',
+ jsonInvalid: 'The JSON body template is invalid',
+ formFieldRequired: 'Form field names are required',
+ formFieldDuplicate: 'Form field names must be unique',
+ headerRequired: 'Header names are required',
+ headerInvalid: 'The header name is invalid',
+ headerDuplicate: 'Header names must be unique',
+ headerReserved: 'This header is managed by the system and cannot be customized',
+ headerMustBeSecret: 'Authentication or credential headers must be marked as secret values',
+ templateVariableInvalid: 'The body contains an unsupported template variable',
+ secretRequired: 'Enter a secret value, or choose Keep unchanged or Clear',
+ },
+ alertConfigProHelper: 'Commercial Edition adds more notification channels.',
recipientPlaceholder: 'Please enter recipient email address',
addRecipient: 'Add Recipient',
webhookItem: 'Webhook',
diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts
index 9ffce81a7..40135c4b6 100644
--- a/frontend/src/lang/modules/es-es.ts
+++ b/frontend/src/lang/modules/es-es.ts
@@ -950,7 +950,6 @@ const message = {
from_remote: 'Este modelo no fue descargado vía 1Panel, no hay registros de descarga relacionados.',
no_logs: 'Los registros de descarga de este modelo han sido eliminados y no se pueden consultar.',
vllmVersionHelper: 'Para servidores FusionXpark GB 10, seleccione la versión -cu130.',
- ascendVisibleDevices: 'Dispositivos Ascend visibles',
vllmCommandPortHelper:
'El comando de inicio debe usar el puerto {0}; de lo contrario, no se podrá acceder al servicio.',
ascendVisibleDevices: 'Dispositivos Ascend visibles (ASCEND_RT_VISIBLE_DEVICES)',
@@ -1146,7 +1145,6 @@ const message = {
cachedToken: 'Tokens en caché',
cacheHitRate: 'Tasa de acierto de caché',
activeUsers: 'Usuarios activos',
- activeStreamingRequests: 'Solicitudes de streaming activas',
activeModels: 'Modelos activos',
failedRequests: 'Solicitudes fallidas',
averageTokenPerRequest: 'Tokens promedio/solicitud',
@@ -6809,7 +6807,58 @@ const message = {
barkConfigHelper: 'Configuración de notificación de alerta Bark',
webhookName: 'Nombre del bot',
webhookUrl: 'URL de Webhook',
- alertConfigProHelper: 'La edición comercial también admite alertas mediante WeCom, DingTalk, Feishu y SMS.',
+ custom: 'Webhook',
+ webhookPreset: 'Preajuste',
+ genericJsonPreset: 'JSON genérico',
+ customPreset: 'Personalizado',
+ webhookUrlSecretHelper: 'La URL del Webhook se almacena cifrada y se puede ver y editar aquí',
+ webhookPublicAddressHelper:
+ 'Solo se admiten direcciones HTTP/HTTPS accesibles públicamente; se bloquean las direcciones locales, privadas y reservadas',
+ customWebhookRecoveryRequired:
+ 'Esta configuración no es válida o procede de una versión anterior. Vuelve a introducir la URL del Webhook y las cabeceras secretas necesarias y guarda los cambios.',
+ clearSecret: 'Borrar',
+ keepSecret: 'Mantener sin cambios',
+ secretCleared: 'Este secreto se borrará al guardar',
+ bodyType: 'Tipo de Body',
+ bodyTemplate: 'Plantilla del Body',
+ formFieldName: 'Nombre del campo',
+ formFieldValue: 'Valor del campo',
+ addFormField: 'Añadir campo',
+ webhookAdvanced: 'Avanzado',
+ headers: 'Headers',
+ headerName: 'Nombre del Header',
+ headerValue: 'Valor del Header',
+ secretValue: 'Valor secreto',
+ addHeader: 'Añadir Header',
+ templateVariables: 'Variables de plantilla',
+ templateVariableTitle: 'Título de alerta',
+ templateVariableMessage: 'Contenido de alerta',
+ templateVariableType: 'Tipo de alerta',
+ templateVariableNodeName: 'Nombre del nodo',
+ templateVariableTimestamp: 'Hora del evento',
+ templateVariablesHelper:
+ 'title=título de la alerta, message=contenido, type=tipo, nodeName=nombre del nodo, timestamp=hora del evento. Haz clic para insertar una variable en el Body.',
+ testResultStale: 'La configuración cambió; el resultado de la prueba anterior ya no es válido',
+ alertConfigChanged: 'La configuración se actualizó. Actualice la página e inténtelo de nuevo.',
+ presetOverwriteHelper: 'Cambiar el preajuste reemplazará la configuración actual del Body. ¿Continuar?',
+ customWebhookValidation: {
+ displayNameRequired: 'Introduce un nombre para mostrar',
+ urlRequired: 'Introduce una URL de Webhook',
+ urlInvalid: 'La URL del Webhook debe ser una URL HTTP o HTTPS válida',
+ bodyRequired: 'Configura el Body de la solicitud',
+ jsonInvalid: 'La plantilla JSON del Body no es válida',
+ formFieldRequired: 'Los nombres de los campos Form son obligatorios',
+ formFieldDuplicate: 'Los nombres de los campos Form deben ser únicos',
+ headerRequired: 'Los nombres de los Headers son obligatorios',
+ headerInvalid: 'El nombre del Header no es válido',
+ headerDuplicate: 'Los nombres de los Headers deben ser únicos',
+ headerReserved: 'Este Header lo gestiona el sistema y no se puede personalizar',
+ headerMustBeSecret:
+ 'Las cabeceras de autenticación o credenciales deben marcarse como valores secretos',
+ templateVariableInvalid: 'El Body contiene una variable de plantilla no compatible',
+ secretRequired: 'Introduce un secreto o elige Mantener sin cambios o Borrar',
+ },
+ alertConfigProHelper: 'La edición comercial añade más canales de notificación.',
},
theme: {
lingXiaGold: 'LXware Gold',
diff --git a/frontend/src/lang/modules/fa.ts b/frontend/src/lang/modules/fa.ts
index af706b1e0..cec435e21 100644
--- a/frontend/src/lang/modules/fa.ts
+++ b/frontend/src/lang/modules/fa.ts
@@ -933,7 +933,6 @@ const message = {
from_remote: 'این مدل از طریق 1Panel دانلود نشده است، لاگ مربوط به دریافت وجود ندارد.',
no_logs: 'لاگ دریافت این مدل حذف شده است و قابل مشاهده نیست.',
vllmVersionHelper: 'برای سرورهای FusionXpark GB 10، لطفاً نسخه -cu130 را انتخاب کنید.',
- ascendVisibleDevices: 'دستگاههای Ascend قابل مشاهده',
vllmCommandPortHelper:
'فرمان راهاندازی باید از پورت {0} استفاده کند؛ در غیر این صورت سرویس قابل دسترسی نخواهد بود.',
ascendVisibleDevices: 'دستگاههای قابل مشاهده Ascend (ASCEND_RT_VISIBLE_DEVICES)',
@@ -1126,7 +1125,6 @@ const message = {
cachedToken: 'توکنهای کش شده',
cacheHitRate: 'نرخ برخورد کش',
activeUsers: 'کاربران فعال',
- activeStreamingRequests: 'درخواستهای جریانی فعال',
activeModels: 'مدلهای فعال',
failedRequests: 'درخواستهای ناموفق',
averageTokenPerRequest: 'میانگین توکن در هر درخواست',
@@ -6657,7 +6655,57 @@ const message = {
feiShuConfigHelper: 'پیکربندی اعلان هشدار فیشو',
webhookName: 'نام ربات',
webhookUrl: 'URL وبهوک',
- alertConfigProHelper: 'نسخه تجاری همچنین از هشدارهای WeCom، دینگتاک، فیشو و پیامک پشتیبانی میکند.',
+ custom: 'Webhook',
+ webhookPreset: 'پیشتنظیم',
+ genericJsonPreset: 'JSON عمومی',
+ customPreset: 'سفارشی',
+ webhookUrlSecretHelper: 'URL وبهوک بهصورت رمزگذاریشده ذخیره میشود و در اینجا قابل مشاهده و ویرایش است',
+ webhookPublicAddressHelper:
+ 'فقط نشانیهای HTTP/HTTPS قابل دسترس از اینترنت عمومی پشتیبانی میشوند؛ نشانیهای محلی، خصوصی و رزروشده مسدود میشوند',
+ customWebhookRecoveryRequired:
+ 'این پیکربندی نامعتبر یا مربوط به نسخهای قدیمی است. URL وبهوک و Headerهای محرمانه موردنیاز را دوباره وارد و ذخیره کنید.',
+ clearSecret: 'پاک کردن',
+ keepSecret: 'بدون تغییر',
+ secretCleared: 'این مقدار محرمانه هنگام ذخیره پاک میشود',
+ bodyType: 'نوع Body',
+ bodyTemplate: 'قالب Body',
+ formFieldName: 'نام فیلد',
+ formFieldValue: 'مقدار فیلد',
+ addFormField: 'افزودن فیلد',
+ webhookAdvanced: 'پیشرفته',
+ headers: 'Headers',
+ headerName: 'نام Header',
+ headerValue: 'مقدار Header',
+ secretValue: 'مقدار محرمانه',
+ addHeader: 'افزودن Header',
+ templateVariables: 'متغیرهای قالب',
+ templateVariableTitle: 'عنوان هشدار',
+ templateVariableMessage: 'محتوای هشدار',
+ templateVariableType: 'نوع هشدار',
+ templateVariableNodeName: 'نام گره',
+ templateVariableTimestamp: 'زمان رخداد',
+ templateVariablesHelper:
+ 'title=عنوان هشدار، message=محتوا، type=نوع، nodeName=نام گره، timestamp=زمان رخداد. برای درج در Body روی متغیر کلیک کنید.',
+ testResultStale: 'پیکربندی تغییر کرده و نتیجه آزمایش قبلی دیگر معتبر نیست',
+ alertConfigChanged: 'پیکربندی بهروزرسانی شده است. صفحه را تازهسازی کرده و دوباره تلاش کنید.',
+ presetOverwriteHelper: 'تغییر پیشتنظیم، پیکربندی فعلی Body را جایگزین میکند. ادامه میدهید؟',
+ customWebhookValidation: {
+ displayNameRequired: 'نام نمایشی را وارد کنید',
+ urlRequired: 'URL وبهوک را وارد کنید',
+ urlInvalid: 'URL وبهوک باید یک آدرس معتبر HTTP یا HTTPS باشد',
+ bodyRequired: 'Body درخواست را پیکربندی کنید',
+ jsonInvalid: 'قالب JSON Body معتبر نیست',
+ formFieldRequired: 'نام فیلدهای Form الزامی است',
+ formFieldDuplicate: 'نام فیلدهای Form باید یکتا باشد',
+ headerRequired: 'نام Headers الزامی است',
+ headerInvalid: 'نام Header معتبر نیست',
+ headerDuplicate: 'نام Headers باید یکتا باشد',
+ headerReserved: 'این Header توسط سیستم مدیریت میشود و قابل سفارشیسازی نیست',
+ headerMustBeSecret: 'Headerهای احراز هویت یا اطلاعات دسترسی باید محرمانه تنظیم شوند',
+ templateVariableInvalid: 'Body شامل متغیر قالب پشتیبانینشده است',
+ secretRequired: 'مقدار محرمانه را وارد کنید یا بدون تغییر/پاک کردن را انتخاب کنید',
+ },
+ alertConfigProHelper: 'نسخه تجاری کانالهای اعلان بیشتری ارائه میکند.',
recipientPlaceholder: 'لطفاً آدرس ایمیل گیرنده را وارد کنید',
addRecipient: 'افزودن گیرنده',
webhookItem: 'وبهوک',
diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts
index 2d51a4287..6fe2c82cb 100644
--- a/frontend/src/lang/modules/ja.ts
+++ b/frontend/src/lang/modules/ja.ts
@@ -937,7 +937,6 @@ const message = {
from_remote: 'このモデルは1Panelを介してダウンロードされておらず、関連するプルログはありません。',
no_logs: 'このモデルのプルログは削除されており、関連するログを表示できません。',
vllmVersionHelper: 'FusionXpark GB 10 サーバーでは -cu130 バージョンを選択してください。',
- ascendVisibleDevices: 'Ascend 可視デバイス',
vllmCommandPortHelper:
'起動コマンドではポート {0} を使用する必要があります。使用しない場合、サービスにアクセスできません。',
ascendVisibleDevices: 'Ascend 可視デバイス(ASCEND_RT_VISIBLE_DEVICES)',
@@ -1129,7 +1128,6 @@ const message = {
cachedToken: 'キャッシュ Token',
cacheHitRate: 'キャッシュヒット率',
activeUsers: 'アクティブユーザー',
- activeStreamingRequests: 'アクティブなストリーミングリクエスト',
activeModels: 'アクティブモデル',
failedRequests: '失敗リクエスト',
averageTokenPerRequest: '平均 Token/リクエスト',
@@ -6699,7 +6697,57 @@ const message = {
barkConfigHelper: 'Barkアラート通知設定',
webhookName: 'ボット名',
webhookUrl: 'Webhook URL',
- alertConfigProHelper: '商用版では、WeCom、DingTalk、Feishu、SMS 通知も利用できます。',
+ custom: 'Webhook',
+ webhookPreset: 'プリセット',
+ genericJsonPreset: '汎用 JSON',
+ customPreset: 'カスタム',
+ webhookUrlSecretHelper: 'Webhook URL は暗号化して保存され、ここで表示および編集できます',
+ webhookPublicAddressHelper:
+ '公開ネットワークから到達可能な HTTP/HTTPS アドレスのみ対応し、ローカル、プライベート、予約済みアドレスは拒否されます',
+ customWebhookRecoveryRequired:
+ 'この設定は無効か旧バージョンのものです。Webhook URL と必要なシークレット Header を再入力して保存してください。',
+ clearSecret: 'クリア',
+ keepSecret: '変更しない',
+ secretCleared: '保存するとこのシークレットはクリアされます',
+ bodyType: 'Body タイプ',
+ bodyTemplate: 'Body テンプレート',
+ formFieldName: 'フィールド名',
+ formFieldValue: 'フィールド値',
+ addFormField: 'フィールドを追加',
+ webhookAdvanced: '詳細設定',
+ headers: 'Headers',
+ headerName: 'Header 名',
+ headerValue: 'Header 値',
+ secretValue: 'シークレット値',
+ addHeader: 'Header を追加',
+ templateVariables: 'テンプレート変数',
+ templateVariableTitle: 'アラートタイトル',
+ templateVariableMessage: 'アラート内容',
+ templateVariableType: 'アラートタイプ',
+ templateVariableNodeName: 'ノード名',
+ templateVariableTimestamp: '発生時刻',
+ templateVariablesHelper:
+ 'title=アラートタイトル、message=アラート内容、type=アラートタイプ、nodeName=ノード名、timestamp=発生時刻。変数をクリックすると Body に挿入できます。',
+ testResultStale: '設定が変更されたため、前回のテスト結果は無効です',
+ alertConfigChanged: '設定が更新されました。更新してからもう一度お試しください',
+ presetOverwriteHelper: 'プリセットを切り替えると現在の Body 設定が上書きされます。続行しますか?',
+ customWebhookValidation: {
+ displayNameRequired: '表示名を入力してください',
+ urlRequired: 'Webhook URL を入力してください',
+ urlInvalid: 'Webhook URL には有効な HTTP または HTTPS URL を指定してください',
+ bodyRequired: 'リクエスト Body を設定してください',
+ jsonInvalid: 'JSON Body テンプレートが無効です',
+ formFieldRequired: 'Form フィールド名は必須です',
+ formFieldDuplicate: 'Form フィールド名は重複できません',
+ headerRequired: 'Header 名は必須です',
+ headerInvalid: 'Header 名が無効です',
+ headerDuplicate: 'Header 名は重複できません',
+ headerReserved: 'この Header はシステム管理のためカスタマイズできません',
+ headerMustBeSecret: '認証情報を含む Header はシークレット値に設定してください',
+ templateVariableInvalid: 'Body に未対応のテンプレート変数が含まれています',
+ secretRequired: 'シークレット値を入力するか、変更しない/クリアを選択してください',
+ },
+ alertConfigProHelper: '商用版では、追加の通知チャネルを利用できます。',
},
theme: {
lingXiaGold: 'LXware Gold',
diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts
index 08303312d..cca8af5c5 100644
--- a/frontend/src/lang/modules/ko.ts
+++ b/frontend/src/lang/modules/ko.ts
@@ -927,7 +927,6 @@ const message = {
from_remote: '이 모델은 1Panel을 통해 다운로드되지 않았으며 관련 풀 로그가 없습니다.',
no_logs: '이 모델의 풀 로그가 삭제되어 관련 로그를 볼 수 없습니다.',
vllmVersionHelper: 'FusionXpark GB 10 서버는 -cu130 버전을 선택하세요.',
- ascendVisibleDevices: 'Ascend 표시 장치',
vllmCommandPortHelper: '시작 명령은 {0} 포트를 사용해야 하며, 그렇지 않으면 서비스에 접근할 수 없습니다.',
ascendVisibleDevices: 'Ascend 표시 장치 (ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: '모델 계정에 동기화',
@@ -1118,7 +1117,6 @@ const message = {
cachedToken: '캐시 Token',
cacheHitRate: '캐시 적중률',
activeUsers: '활성 사용자',
- activeStreamingRequests: '활성 스트리밍 요청',
activeModels: '활성 모델',
failedRequests: '실패한 요청',
averageTokenPerRequest: '평균 Token/요청',
@@ -6563,7 +6561,57 @@ const message = {
barkConfigHelper: 'Bark 알림 구성',
webhookName: '봇 이름',
webhookUrl: 'Webhook URL',
- alertConfigProHelper: '상용 버전에서는 WeCom, DingTalk, Feishu, SMS 알림도 지원합니다.',
+ custom: 'Webhook',
+ webhookPreset: '프리셋',
+ genericJsonPreset: '일반 JSON',
+ customPreset: '사용자 지정',
+ webhookUrlSecretHelper: 'Webhook URL은 암호화되어 저장되며 여기에서 확인하고 편집할 수 있습니다',
+ webhookPublicAddressHelper:
+ '공개 네트워크에서 접근 가능한 HTTP/HTTPS 주소만 지원하며 로컬, 사설 및 예약 주소는 차단됩니다',
+ customWebhookRecoveryRequired:
+ '이 설정은 유효하지 않거나 이전 버전에서 생성되었습니다. Webhook URL과 필요한 비밀 Header를 다시 입력한 후 저장하세요.',
+ clearSecret: '지우기',
+ keepSecret: '변경하지 않음',
+ secretCleared: '저장하면 이 비밀 값이 지워집니다',
+ bodyType: 'Body 유형',
+ bodyTemplate: 'Body 템플릿',
+ formFieldName: '필드 이름',
+ formFieldValue: '필드 값',
+ addFormField: '필드 추가',
+ webhookAdvanced: '고급 설정',
+ headers: 'Headers',
+ headerName: 'Header 이름',
+ headerValue: 'Header 값',
+ secretValue: '비밀 값',
+ addHeader: 'Header 추가',
+ templateVariables: '템플릿 변수',
+ templateVariableTitle: '알림 제목',
+ templateVariableMessage: '알림 내용',
+ templateVariableType: '알림 유형',
+ templateVariableNodeName: '노드 이름',
+ templateVariableTimestamp: '발생 시간',
+ templateVariablesHelper:
+ 'title=알림 제목, message=알림 내용, type=알림 유형, nodeName=노드 이름, timestamp=발생 시간. 변수를 클릭하면 Body에 삽입됩니다.',
+ testResultStale: '설정이 변경되어 이전 테스트 결과가 더 이상 유효하지 않습니다',
+ alertConfigChanged: '구성이 업데이트되었습니다. 새로 고친 후 다시 시도하세요.',
+ presetOverwriteHelper: '프리셋을 전환하면 현재 Body 설정을 덮어씁니다. 계속하시겠습니까?',
+ customWebhookValidation: {
+ displayNameRequired: '표시 이름을 입력하세요',
+ urlRequired: 'Webhook URL을 입력하세요',
+ urlInvalid: 'Webhook URL은 유효한 HTTP 또는 HTTPS URL이어야 합니다',
+ bodyRequired: '요청 Body를 설정하세요',
+ jsonInvalid: 'JSON Body 템플릿이 올바르지 않습니다',
+ formFieldRequired: 'Form 필드 이름은 필수입니다',
+ formFieldDuplicate: 'Form 필드 이름은 중복될 수 없습니다',
+ headerRequired: 'Header 이름은 필수입니다',
+ headerInvalid: 'Header 이름이 올바르지 않습니다',
+ headerDuplicate: 'Header 이름은 중복될 수 없습니다',
+ headerReserved: '이 Header는 시스템에서 관리하므로 사용자 지정할 수 없습니다',
+ headerMustBeSecret: '인증 또는 자격 증명 Header는 비밀 값으로 설정해야 합니다',
+ templateVariableInvalid: 'Body에 지원되지 않는 템플릿 변수가 있습니다',
+ secretRequired: '비밀 값을 입력하거나 변경하지 않음/지우기를 선택하세요',
+ },
+ alertConfigProHelper: '상용 버전에서는 더 많은 알림 채널을 지원합니다.',
},
theme: {
lingXiaGold: 'LXware Gold',
diff --git a/frontend/src/lang/modules/lo.ts b/frontend/src/lang/modules/lo.ts
index e3f4681c3..0591bbcce 100644
--- a/frontend/src/lang/modules/lo.ts
+++ b/frontend/src/lang/modules/lo.ts
@@ -927,7 +927,6 @@ const message = {
from_remote: 'ໂມເດວນີ້ບໍ່ໄດ້ດາວໂຫຼດຜ່ານ 1Panel, ບໍ່ມີລັອກການດຶງຂໍ້ມູນທີ່ກ່ຽວຂ້ອງ.',
no_logs: 'ລັອກການດຶງຂໍ້ມູນຂອງໂມເດວນີ້ຖືກລຶບແລ້ວ ແລະ ບໍ່ສາມາດເບິ່ງໄດ້.',
vllmVersionHelper: 'ສຳລັບເຊີເວີ FusionXpark GB 10, ກະລຸນາເລືອກເວີຊັນ -cu130.',
- ascendVisibleDevices: 'ອຸປະກອນ Ascend ທີ່ເຫັນໄດ້',
vllmCommandPortHelper: 'ຄຳສັ່ງເລີ່ມຕົ້ນຕ້ອງໃຊ້ພອດ {0}; ບໍ່ດັ່ງນັ້ນຈະບໍ່ສາມາດເຂົ້າເຖິງບໍລິການໄດ້.',
ascendVisibleDevices: 'ອຸປະກອນ Ascend ທີ່ເຫັນໄດ້ (ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: 'ຊິ້ງຄ໌ໄປຍັງບັນຊີໂມເດວ',
@@ -1037,7 +1036,6 @@ const message = {
gatewayConcurrency: 'ການເຮັດວຽກພ້ອມກັນຂອງເກດເວປັດຈຸບັນ',
waitingQueue: 'ຄິວລໍຖ້າປັດຈຸບັນ',
currentActiveUsers: 'ຜູ້ໃຊ້ທີ່ກຳລັງໃຊ້ງານ',
- activeStreamingRequests: 'ຄຳຮ້ອງຂໍສະຕຣີມທີ່ກຳລັງໃຊ້ງານ',
modelAccountConcurrency: 'ການເຮັດວຽກພ້ອມກັນຂອງບັນຊີໂມເດວ',
accountAvailability: 'ຄວາມພ້ອມໃຊ້ງານຂອງບັນຊີໂມເດວ',
capacityFull: 'ຄວາມຈຸເຕັມ',
@@ -6476,7 +6474,57 @@ const message = {
feiShuConfigHelper: 'ຕັ້ງຄ່າການແຈ້ງເຕືອນ Feishu',
webhookName: 'ຊື່ບັອດ',
webhookUrl: 'Webhook URL',
- alertConfigProHelper: 'ເວີຊັນ Commercial ຮອງຮັບ WeCom, DingTalk, Feishu ແລະ SMS.',
+ custom: 'Webhook',
+ webhookPreset: 'ຄ່າສຳເລັດ',
+ genericJsonPreset: 'JSON ທົ່ວໄປ',
+ customPreset: 'ກຳນົດເອງ',
+ webhookUrlSecretHelper: 'Webhook URL ຈະຖືກເກັບແບບເຂົ້າລະຫັດ ແລະ ສາມາດເບິ່ງ ຫຼື ແກ້ໄຂໄດ້ຢູ່ບ່ອນນີ້',
+ webhookPublicAddressHelper:
+ 'ຮອງຮັບສະເພາະທີ່ຢູ່ HTTP/HTTPS ທີ່ເຂົ້າເຖິງໄດ້ຈາກເຄືອຂ່າຍສາທາລະນະ; ທີ່ຢູ່ພາຍໃນ, ສ່ວນຕົວ ແລະ ສຳຮອງຈະຖືກບລັອກ',
+ customWebhookRecoveryRequired:
+ 'ການຕັ້ງຄ່ານີ້ບໍ່ຖືກຕ້ອງ ຫຼື ມາຈາກເວີຊັນເກົ່າ. ກະລຸນາປ້ອນ Webhook URL ແລະ Header ລັບທີ່ຈຳເປັນໃໝ່ ແລ້ວບັນທຶກ.',
+ clearSecret: 'ລ້າງ',
+ keepSecret: 'ບໍ່ປ່ຽນແປງ',
+ secretCleared: 'ຄ່າລັບນີ້ຈະຖືກລ້າງເມື່ອບັນທຶກ',
+ bodyType: 'ປະເພດ Body',
+ bodyTemplate: 'ແມ່ແບບ Body',
+ formFieldName: 'ຊື່ຟິວ',
+ formFieldValue: 'ຄ່າຟິວ',
+ addFormField: 'ເພີ່ມຟິວ',
+ webhookAdvanced: 'ຂັ້ນສູງ',
+ headers: 'Headers',
+ headerName: 'ຊື່ Header',
+ headerValue: 'ຄ່າ Header',
+ secretValue: 'ຄ່າລັບ',
+ addHeader: 'ເພີ່ມ Header',
+ templateVariables: 'ຕົວແປແມ່ແບບ',
+ templateVariableTitle: 'ຫົວຂໍ້ແຈ້ງເຕືອນ',
+ templateVariableMessage: 'ເນື້ອຫາແຈ້ງເຕືອນ',
+ templateVariableType: 'ປະເພດແຈ້ງເຕືອນ',
+ templateVariableNodeName: 'ຊື່ໂນດ',
+ templateVariableTimestamp: 'ເວລາເກີດເຫດ',
+ templateVariablesHelper:
+ 'title=ຫົວຂໍ້ແຈ້ງເຕືອນ, message=ເນື້ອຫາ, type=ປະເພດ, nodeName=ຊື່ໂນດ, timestamp=ເວລາເກີດເຫດ. ຄລິກຕົວແປເພື່ອແຊກໃນ Body.',
+ testResultStale: 'ການຕັ້ງຄ່າປ່ຽນແລ້ວ; ຜົນທົດສອບກ່ອນໜ້າບໍ່ຖືກຕ້ອງອີກ',
+ alertConfigChanged: 'ການຕັ້ງຄ່າຖືກອັບເດດແລ້ວ. ໂຫຼດໃໝ່ແລ້ວລອງອີກຄັ້ງ.',
+ presetOverwriteHelper: 'ການປ່ຽນຄ່າສຳເລັດຈະແທນການຕັ້ງຄ່າ Body ປັດຈຸບັນ. ສືບຕໍ່ບໍ?',
+ customWebhookValidation: {
+ displayNameRequired: 'ປ້ອນຊື່ສະແດງ',
+ urlRequired: 'ປ້ອນ Webhook URL',
+ urlInvalid: 'Webhook URL ຕ້ອງເປັນ HTTP ຫຼື HTTPS ທີ່ຖືກຕ້ອງ',
+ bodyRequired: 'ຕັ້ງຄ່າ Body ຂອງຄຳຂໍ',
+ jsonInvalid: 'ແມ່ແບບ JSON Body ບໍ່ຖືກຕ້ອງ',
+ formFieldRequired: 'ຊື່ຟິວ Form ແມ່ນຈຳເປັນ',
+ formFieldDuplicate: 'ຊື່ຟິວ Form ຕ້ອງບໍ່ຊ້ຳກັນ',
+ headerRequired: 'ຊື່ Headers ແມ່ນຈຳເປັນ',
+ headerInvalid: 'ຊື່ Header ບໍ່ຖືກຕ້ອງ',
+ headerDuplicate: 'ຊື່ Headers ຕ້ອງບໍ່ຊ້ຳກັນ',
+ headerReserved: 'Header ນີ້ຖືກຈັດການໂດຍລະບົບ ແລະ ປັບແຕ່ງບໍ່ໄດ້',
+ headerMustBeSecret: 'Header ການຢືນຢັນ ຫຼື ຂໍ້ມູນຮັບຮອງຕ້ອງຕັ້ງເປັນຄ່າລັບ',
+ templateVariableInvalid: 'Body ມີຕົວແປແມ່ແບບທີ່ບໍ່ຮອງຮັບ',
+ secretRequired: 'ປ້ອນຄ່າລັບ ຫຼື ເລືອກບໍ່ປ່ຽນແປງ/ລ້າງ',
+ },
+ alertConfigProHelper: 'ເວີຊັນ Commercial ເພີ່ມຊ່ອງທາງແຈ້ງເຕືອນອື່ນໆ.',
recipientPlaceholder: 'ກະລຸນາປ້ອນທີ່ຢູ່ອີເມລຜູ້ຮັບ',
addRecipient: 'ເພີ່ມຜູ້ຮັບ',
webhookItem: 'Webhook',
diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts
index 78ad94245..09ced39dc 100644
--- a/frontend/src/lang/modules/ms.ts
+++ b/frontend/src/lang/modules/ms.ts
@@ -958,7 +958,6 @@ const message = {
from_remote: 'Model ini tidak dimuat turun melalui 1Panel; tiada log muat turun berkaitan.',
no_logs: 'Log muat turun model ini telah dipadam dan tidak boleh dilihat.',
vllmVersionHelper: 'Untuk pelayan FusionXpark GB 10, sila pilih versi -cu130.',
- ascendVisibleDevices: 'Peranti Ascend yang kelihatan',
vllmCommandPortHelper:
'Perintah permulaan mesti menggunakan port {0}; jika tidak, perkhidmatan tidak dapat diakses.',
ascendVisibleDevices: 'Peranti Ascend boleh dilihat (ASCEND_RT_VISIBLE_DEVICES)',
@@ -1151,7 +1150,6 @@ const message = {
cachedToken: 'Token cache',
cacheHitRate: 'Kadar hit cache',
activeUsers: 'Pengguna aktif',
- activeStreamingRequests: 'Permintaan penstriman aktif',
activeModels: 'Model aktif',
failedRequests: 'Permintaan gagal',
averageTokenPerRequest: 'Purata Token/permintaan',
@@ -6809,7 +6807,57 @@ const message = {
barkConfigHelper: 'Konfigurasi pemberitahuan amaran Bark',
webhookName: 'Nama bot',
webhookUrl: 'URL Webhook',
- alertConfigProHelper: 'Edisi Komersial turut menyokong amaran WeCom, DingTalk, Feishu dan SMS.',
+ custom: 'Webhook',
+ webhookPreset: 'Pratetap',
+ genericJsonPreset: 'JSON umum',
+ customPreset: 'Tersuai',
+ webhookUrlSecretHelper: 'URL Webhook disimpan secara disulitkan dan boleh dilihat serta diedit di sini',
+ webhookPublicAddressHelper:
+ 'Hanya alamat HTTP/HTTPS yang boleh dicapai secara awam disokong; alamat setempat, peribadi dan simpanan disekat',
+ customWebhookRecoveryRequired:
+ 'Konfigurasi ini tidak sah atau daripada versi lama. Masukkan semula URL Webhook dan Header rahsia yang diperlukan, kemudian simpan.',
+ clearSecret: 'Kosongkan',
+ keepSecret: 'Kekalkan',
+ secretCleared: 'Rahsia ini akan dikosongkan apabila disimpan',
+ bodyType: 'Jenis Body',
+ bodyTemplate: 'Templat Body',
+ formFieldName: 'Nama medan',
+ formFieldValue: 'Nilai medan',
+ addFormField: 'Tambah medan',
+ webhookAdvanced: 'Lanjutan',
+ headers: 'Headers',
+ headerName: 'Nama Header',
+ headerValue: 'Nilai Header',
+ secretValue: 'Nilai rahsia',
+ addHeader: 'Tambah Header',
+ templateVariables: 'Pemboleh ubah templat',
+ templateVariableTitle: 'Tajuk amaran',
+ templateVariableMessage: 'Kandungan amaran',
+ templateVariableType: 'Jenis amaran',
+ templateVariableNodeName: 'Nama nod',
+ templateVariableTimestamp: 'Masa kejadian',
+ templateVariablesHelper:
+ 'title=tajuk amaran, message=kandungan, type=jenis, nodeName=nama nod, timestamp=masa kejadian. Klik pemboleh ubah untuk memasukkannya ke dalam Body.',
+ testResultStale: 'Konfigurasi telah berubah; keputusan ujian sebelumnya tidak lagi sah',
+ alertConfigChanged: 'Konfigurasi telah dikemas kini. Muat semula dan cuba lagi.',
+ presetOverwriteHelper: 'Menukar pratetap akan menggantikan konfigurasi Body semasa. Teruskan?',
+ customWebhookValidation: {
+ displayNameRequired: 'Masukkan nama paparan',
+ urlRequired: 'Masukkan URL Webhook',
+ urlInvalid: 'URL Webhook mestilah URL HTTP atau HTTPS yang sah',
+ bodyRequired: 'Konfigurasikan Body permintaan',
+ jsonInvalid: 'Templat JSON Body tidak sah',
+ formFieldRequired: 'Nama medan Form diperlukan',
+ formFieldDuplicate: 'Nama medan Form mestilah unik',
+ headerRequired: 'Nama Headers diperlukan',
+ headerInvalid: 'Nama Header tidak sah',
+ headerDuplicate: 'Nama Headers mestilah unik',
+ headerReserved: 'Header ini diurus oleh sistem dan tidak boleh disesuaikan',
+ headerMustBeSecret: 'Header pengesahan atau kelayakan mesti ditandai sebagai nilai rahsia',
+ templateVariableInvalid: 'Body mengandungi pemboleh ubah templat yang tidak disokong',
+ secretRequired: 'Masukkan nilai rahsia atau pilih Kekalkan atau Kosongkan',
+ },
+ alertConfigProHelper: 'Edisi Komersial menambah lebih banyak saluran pemberitahuan.',
},
theme: {
lingXiaGold: 'LXware Gold',
diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts
index b5c4c2f1a..52eb90409 100644
--- a/frontend/src/lang/modules/pt-br.ts
+++ b/frontend/src/lang/modules/pt-br.ts
@@ -954,7 +954,6 @@ const message = {
from_remote: 'Este modelo não foi baixado pelo 1Panel; não há logs de download relacionados.',
no_logs: 'Os logs de download deste modelo foram excluídos e não podem ser visualizados.',
vllmVersionHelper: 'Para servidores FusionXpark GB 10, selecione a versão -cu130.',
- ascendVisibleDevices: 'Dispositivos Ascend visíveis',
vllmCommandPortHelper:
'O comando de inicialização deve usar a porta {0}; caso contrário, o serviço ficará inacessível.',
ascendVisibleDevices: 'Dispositivos Ascend visíveis (ASCEND_RT_VISIBLE_DEVICES)',
@@ -1148,7 +1147,6 @@ const message = {
cachedToken: 'Tokens em cache',
cacheHitRate: 'Taxa de acerto do cache',
activeUsers: 'Usuários ativos',
- activeStreamingRequests: 'Solicitações de streaming ativas',
activeModels: 'Modelos ativos',
failedRequests: 'Requisições com falha',
averageTokenPerRequest: 'Média de Token/requisição',
@@ -6848,8 +6846,57 @@ const message = {
barkConfigHelper: 'Configuração de notificação de alerta Bark',
webhookName: 'Nome do bot',
webhookUrl: 'URL do Webhook',
- alertConfigProHelper:
- 'A edição comercial também oferece suporte a alertas via WeCom, DingTalk, Feishu e SMS.',
+ custom: 'Webhook',
+ webhookPreset: 'Predefinição',
+ genericJsonPreset: 'JSON genérico',
+ customPreset: 'Personalizado',
+ webhookUrlSecretHelper: 'A URL do Webhook é armazenada criptografada e pode ser vista e editada aqui',
+ webhookPublicAddressHelper:
+ 'Somente endereços HTTP/HTTPS acessíveis publicamente são aceitos; endereços locais, privados e reservados são bloqueados',
+ customWebhookRecoveryRequired:
+ 'Esta configuração é inválida ou veio de uma versão anterior. Informe novamente a URL do Webhook e os headers secretos necessários e salve.',
+ clearSecret: 'Limpar',
+ keepSecret: 'Manter inalterado',
+ secretCleared: 'Este segredo será limpo ao salvar',
+ bodyType: 'Tipo do Body',
+ bodyTemplate: 'Modelo do Body',
+ formFieldName: 'Nome do campo',
+ formFieldValue: 'Valor do campo',
+ addFormField: 'Adicionar campo',
+ webhookAdvanced: 'Avançado',
+ headers: 'Headers',
+ headerName: 'Nome do Header',
+ headerValue: 'Valor do Header',
+ secretValue: 'Valor secreto',
+ addHeader: 'Adicionar Header',
+ templateVariables: 'Variáveis do modelo',
+ templateVariableTitle: 'Título do alerta',
+ templateVariableMessage: 'Conteúdo do alerta',
+ templateVariableType: 'Tipo de alerta',
+ templateVariableNodeName: 'Nome do nó',
+ templateVariableTimestamp: 'Hora do evento',
+ templateVariablesHelper:
+ 'title=título do alerta, message=conteúdo, type=tipo, nodeName=nome do nó, timestamp=hora do evento. Clique para inserir a variável no Body.',
+ testResultStale: 'A configuração foi alterada; o resultado do teste anterior não é mais válido',
+ alertConfigChanged: 'A configuração foi atualizada. Atualize a página e tente novamente.',
+ presetOverwriteHelper: 'Trocar a predefinição substituirá a configuração atual do Body. Continuar?',
+ customWebhookValidation: {
+ displayNameRequired: 'Informe um nome de exibição',
+ urlRequired: 'Informe uma URL de Webhook',
+ urlInvalid: 'A URL do Webhook deve ser uma URL HTTP ou HTTPS válida',
+ bodyRequired: 'Configure o Body da solicitação',
+ jsonInvalid: 'O modelo JSON do Body é inválido',
+ formFieldRequired: 'Os nomes dos campos Form são obrigatórios',
+ formFieldDuplicate: 'Os nomes dos campos Form devem ser únicos',
+ headerRequired: 'Os nomes dos Headers são obrigatórios',
+ headerInvalid: 'O nome do Header é inválido',
+ headerDuplicate: 'Os nomes dos Headers devem ser únicos',
+ headerReserved: 'Este Header é gerenciado pelo sistema e não pode ser personalizado',
+ headerMustBeSecret: 'Headers de autenticação ou credenciais devem ser marcados como valores secretos',
+ templateVariableInvalid: 'O Body contém uma variável de modelo não compatível',
+ secretRequired: 'Informe um segredo ou escolha Manter inalterado ou Limpar',
+ },
+ alertConfigProHelper: 'A edição comercial adiciona mais canais de notificação.',
},
theme: {
lingXiaGold: 'LXware Gold',
diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts
index 9c66e0b9d..f8c7a10f4 100644
--- a/frontend/src/lang/modules/ru.ts
+++ b/frontend/src/lang/modules/ru.ts
@@ -947,7 +947,6 @@ const message = {
from_remote: 'Эта модель не была загружена через 1Panel, нет связанных журналов извлечения.',
no_logs: 'Журналы извлечения для этой модели были удалены и не могут быть просмотрены.',
vllmVersionHelper: 'Для серверов FusionXpark GB 10 выберите версию -cu130.',
- ascendVisibleDevices: 'Видимые устройства Ascend',
vllmCommandPortHelper: 'Команда запуска должна использовать порт {0}, иначе сервис будет недоступен.',
ascendVisibleDevices: 'Видимые устройства Ascend (ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: 'Синхронизировать с аккаунтом модели',
@@ -1139,7 +1138,6 @@ const message = {
cachedToken: 'Кэшированные Token',
cacheHitRate: 'Попадания в кэш',
activeUsers: 'Активные пользователи',
- activeStreamingRequests: 'Активные потоковые запросы',
activeModels: 'Активные модели',
failedRequests: 'Неуспешные запросы',
averageTokenPerRequest: 'Среднее Token/запрос',
@@ -6817,8 +6815,58 @@ const message = {
barkConfigHelper: 'Конфигурация уведомлений Bark',
webhookName: 'Имя бота',
webhookUrl: 'URL Webhook',
- alertConfigProHelper:
- 'Коммерческая версия дополнительно поддерживает уведомления через WeCom, DingTalk, Feishu и SMS.',
+ custom: 'Webhook',
+ webhookPreset: 'Предустановка',
+ genericJsonPreset: 'Универсальный JSON',
+ customPreset: 'Пользовательский',
+ webhookUrlSecretHelper:
+ 'URL Webhook хранится в зашифрованном виде; здесь его можно просматривать и изменять',
+ webhookPublicAddressHelper:
+ 'Поддерживаются только общедоступные адреса HTTP/HTTPS; локальные, частные и зарезервированные адреса блокируются',
+ customWebhookRecoveryRequired:
+ 'Эта конфигурация недействительна или создана в старой версии. Повторно укажите URL Webhook и необходимые секретные заголовки, затем сохраните.',
+ clearSecret: 'Очистить',
+ keepSecret: 'Не изменять',
+ secretCleared: 'Этот секрет будет очищен при сохранении',
+ bodyType: 'Тип Body',
+ bodyTemplate: 'Шаблон Body',
+ formFieldName: 'Имя поля',
+ formFieldValue: 'Значение поля',
+ addFormField: 'Добавить поле',
+ webhookAdvanced: 'Дополнительно',
+ headers: 'Headers',
+ headerName: 'Имя Header',
+ headerValue: 'Значение Header',
+ secretValue: 'Секретное значение',
+ addHeader: 'Добавить Header',
+ templateVariables: 'Переменные шаблона',
+ templateVariableTitle: 'Заголовок оповещения',
+ templateVariableMessage: 'Содержимое оповещения',
+ templateVariableType: 'Тип оповещения',
+ templateVariableNodeName: 'Имя узла',
+ templateVariableTimestamp: 'Время события',
+ templateVariablesHelper:
+ 'title=заголовок, message=содержимое, type=тип, nodeName=имя узла, timestamp=время события. Нажмите переменную, чтобы вставить её в Body.',
+ testResultStale: 'Конфигурация изменена, поэтому предыдущий результат теста больше недействителен',
+ alertConfigChanged: 'Конфигурация обновлена. Обновите страницу и повторите попытку.',
+ presetOverwriteHelper: 'Смена предустановки заменит текущую конфигурацию Body. Продолжить?',
+ customWebhookValidation: {
+ displayNameRequired: 'Введите отображаемое имя',
+ urlRequired: 'Введите URL Webhook',
+ urlInvalid: 'URL Webhook должен быть корректным адресом HTTP или HTTPS',
+ bodyRequired: 'Настройте Body запроса',
+ jsonInvalid: 'Недопустимый шаблон JSON Body',
+ formFieldRequired: 'Имена полей Form обязательны',
+ formFieldDuplicate: 'Имена полей Form не должны повторяться',
+ headerRequired: 'Имена Headers обязательны',
+ headerInvalid: 'Недопустимое имя Header',
+ headerDuplicate: 'Имена Headers не должны повторяться',
+ headerReserved: 'Этот Header управляется системой и не может быть изменён',
+ headerMustBeSecret: 'Заголовки аутентификации или учетных данных должны быть отмечены как секретные',
+ templateVariableInvalid: 'Body содержит неподдерживаемую переменную шаблона',
+ secretRequired: 'Введите секрет или выберите Не изменять либо Очистить',
+ },
+ alertConfigProHelper: 'Коммерческая версия добавляет дополнительные каналы уведомлений.',
},
theme: {
lingXiaGold: 'LXware Gold',
diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts
index 7745bc810..751b2ab65 100644
--- a/frontend/src/lang/modules/tr.ts
+++ b/frontend/src/lang/modules/tr.ts
@@ -956,7 +956,6 @@ const message = {
from_remote: 'Bu model 1Panel aracılığıyla indirilmedi, ilgili çekme logları yok.',
no_logs: 'Bu modelin çekme logları silindi ve görüntülenemiyor.',
vllmVersionHelper: 'FusionXpark GB 10 sunucuları için lütfen -cu130 sürümünü seçin.',
- ascendVisibleDevices: 'Görünür Ascend cihazları',
vllmCommandPortHelper:
'Başlatma komutu {0} numaralı bağlantı noktasını kullanmalıdır; aksi halde hizmete erişilemez.',
ascendVisibleDevices: 'Görünür Ascend cihazları (ASCEND_RT_VISIBLE_DEVICES)',
@@ -1148,7 +1147,6 @@ const message = {
cachedToken: 'Önbellek Token',
cacheHitRate: 'Önbellek İsabet Oranı',
activeUsers: 'Aktif kullanıcılar',
- activeStreamingRequests: 'Etkin akış istekleri',
activeModels: 'Aktif modeller',
failedRequests: 'Başarısız istekler',
averageTokenPerRequest: 'Ortalama Token/istek',
@@ -6817,7 +6815,58 @@ const message = {
barkConfigHelper: 'Bark uyarı bildirim yapılandırması',
webhookName: 'Bot adı',
webhookUrl: 'Webhook URL',
- alertConfigProHelper: 'Ticari sürüm ayrıca WeCom, DingTalk, Feishu ve SMS bildirimlerini destekler.',
+ custom: 'Webhook',
+ webhookPreset: 'Ön ayar',
+ genericJsonPreset: 'Genel JSON',
+ customPreset: 'Özel',
+ webhookUrlSecretHelper: 'Webhook URL şifreli olarak saklanır ve burada görüntülenip düzenlenebilir',
+ webhookPublicAddressHelper:
+ 'Yalnızca genel ağdan erişilebilen HTTP/HTTPS adresleri desteklenir; yerel, özel ve ayrılmış adresler engellenir',
+ customWebhookRecoveryRequired:
+ 'Bu yapılandırma geçersiz veya eski bir sürümden geliyor. Webhook URL ve gerekli gizli Header değerlerini yeniden girip kaydedin.',
+ clearSecret: 'Temizle',
+ keepSecret: 'Değiştirme',
+ secretCleared: 'Kaydettiğinizde bu gizli değer temizlenecek',
+ bodyType: 'Body türü',
+ bodyTemplate: 'Body şablonu',
+ formFieldName: 'Alan adı',
+ formFieldValue: 'Alan değeri',
+ addFormField: 'Alan ekle',
+ webhookAdvanced: 'Gelişmiş',
+ headers: 'Headers',
+ headerName: 'Header adı',
+ headerValue: 'Header değeri',
+ secretValue: 'Gizli değer',
+ addHeader: 'Header ekle',
+ templateVariables: 'Şablon değişkenleri',
+ templateVariableTitle: 'Uyarı başlığı',
+ templateVariableMessage: 'Uyarı içeriği',
+ templateVariableType: 'Uyarı türü',
+ templateVariableNodeName: 'Düğüm adı',
+ templateVariableTimestamp: 'Olay zamanı',
+ templateVariablesHelper:
+ 'title=uyarı başlığı, message=içerik, type=tür, nodeName=düğüm adı, timestamp=olay zamanı. Body içine eklemek için değişkene tıklayın.',
+ testResultStale: 'Yapılandırma değişti; önceki test sonucu artık geçerli değil',
+ alertConfigChanged: 'Yapılandırma güncellendi. Yenileyip tekrar deneyin.',
+ presetOverwriteHelper: 'Ön ayarı değiştirmek mevcut Body yapılandırmasını değiştirecek. Devam edilsin mi?',
+ customWebhookValidation: {
+ displayNameRequired: 'Bir görünen ad girin',
+ urlRequired: 'Bir Webhook URL girin',
+ urlInvalid: 'Webhook URL geçerli bir HTTP veya HTTPS URL olmalıdır',
+ bodyRequired: 'İstek Body içeriğini yapılandırın',
+ jsonInvalid: 'JSON Body şablonu geçersiz',
+ formFieldRequired: 'Form alan adları zorunludur',
+ formFieldDuplicate: 'Form alan adları benzersiz olmalıdır',
+ headerRequired: 'Header adları zorunludur',
+ headerInvalid: 'Header adı geçersiz',
+ headerDuplicate: 'Header adları benzersiz olmalıdır',
+ headerReserved: 'Bu Header sistem tarafından yönetilir ve özelleştirilemez',
+ headerMustBeSecret:
+ 'Kimlik doğrulama veya kimlik bilgisi Header alanları gizli değer olarak işaretlenmelidir',
+ templateVariableInvalid: 'Body desteklenmeyen bir şablon değişkeni içeriyor',
+ secretRequired: 'Bir gizli değer girin veya Değiştirme ya da Temizle seçeneğini kullanın',
+ },
+ alertConfigProHelper: 'Ticari sürüm daha fazla bildirim kanalı ekler.',
},
theme: {
lingXiaGold: 'LXware Gold',
diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts
index 32423c5c2..e72f95f21 100644
--- a/frontend/src/lang/modules/zh-Hant.ts
+++ b/frontend/src/lang/modules/zh-Hant.ts
@@ -898,7 +898,6 @@ const message = {
from_remote: '該模型並非透過 1Panel 下載,無相關拉取日誌。',
no_logs: '該模型的拉取日誌已被刪除,無法檢視相關日誌。',
vllmVersionHelper: 'FusionXpark GB 10 伺服器請選擇 -cu130 版本',
- ascendVisibleDevices: 'Ascend 可見裝置',
vllmCommandPortHelper: '啟動命令必須使用 {0} 連接埠,否則服務將無法存取。',
ascendVisibleDevices: 'Ascend 可見裝置(ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: '同步到模型帳號',
@@ -1083,7 +1082,6 @@ const message = {
cachedToken: '快取 Token',
cacheHitRate: '快取命中率',
activeUsers: '活躍使用者',
- activeStreamingRequests: '活躍串流請求',
activeModels: '活躍模型',
failedRequests: '失敗請求',
averageTokenPerRequest: '平均 Token/請求',
@@ -6229,7 +6227,56 @@ const message = {
barkConfigHelper: 'Bark 告警通知設定',
webhookName: '機器人名稱',
webhookUrl: 'Webhook 位址',
- alertConfigProHelper: '商業版額外支援企業微信、釘釘、飛書及簡訊告警。',
+ custom: 'Webhook',
+ webhookPreset: '預設',
+ genericJsonPreset: '通用 JSON',
+ customPreset: '自訂',
+ webhookUrlSecretHelper: 'Webhook 位址會加密儲存,可在此檢視與編輯',
+ webhookPublicAddressHelper: '僅支援可由公網存取的 HTTP/HTTPS 位址;本機、內網和保留位址會被攔截',
+ customWebhookRecoveryRequired:
+ '目前設定已失效或來自舊版本,請重新填寫 Webhook 位址及所需的敏感 Header 後儲存',
+ clearSecret: '清除',
+ keepSecret: '保持不變',
+ secretCleared: '儲存後將清除此敏感值',
+ bodyType: 'Body 類型',
+ bodyTemplate: 'Body 範本',
+ formFieldName: '欄位名稱',
+ formFieldValue: '欄位值',
+ addFormField: '新增欄位',
+ webhookAdvanced: '進階設定',
+ headers: 'Headers',
+ headerName: 'Header 名稱',
+ headerValue: 'Header 值',
+ secretValue: '敏感值',
+ addHeader: '新增 Header',
+ templateVariables: '範本變數',
+ templateVariableTitle: '告警標題',
+ templateVariableMessage: '告警內容',
+ templateVariableType: '告警類型',
+ templateVariableNodeName: '節點名稱',
+ templateVariableTimestamp: '發生時間',
+ templateVariablesHelper:
+ 'title=告警標題,message=告警內容,type=告警類型,nodeName=節點名稱,timestamp=發生時間;點選變數可插入 Body。',
+ testResultStale: '設定已變更,先前的測試結果已失效',
+ alertConfigChanged: '設定已更新,請重新整理後再試',
+ presetOverwriteHelper: '切換預設將覆寫目前的 Body 設定,是否繼續?',
+ customWebhookValidation: {
+ displayNameRequired: '請輸入顯示名稱',
+ urlRequired: '請輸入 Webhook 位址',
+ urlInvalid: 'Webhook 位址必須是有效的 HTTP 或 HTTPS 位址',
+ bodyRequired: '請設定請求 Body',
+ jsonInvalid: 'JSON Body 範本格式無效',
+ formFieldRequired: 'Form 欄位名稱不能為空',
+ formFieldDuplicate: 'Form 欄位名稱不能重複',
+ headerRequired: 'Header 名稱不能為空',
+ headerInvalid: 'Header 名稱格式無效',
+ headerDuplicate: 'Header 名稱不能重複',
+ headerReserved: '此 Header 由系統管理,無法自訂',
+ headerMustBeSecret: '驗證或憑證類 Header 必須設為敏感值',
+ templateVariableInvalid: 'Body 包含不支援的範本變數',
+ secretRequired: '請輸入敏感值,或選擇保持不變/清除',
+ },
+ alertConfigProHelper: '商業版額外支援更多通知通道。',
},
theme: {
lingXiaGold: 'LXware Gold',
diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts
index 011386029..f06d92ee7 100644
--- a/frontend/src/lang/modules/zh.ts
+++ b/frontend/src/lang/modules/zh.ts
@@ -911,7 +911,6 @@ const message = {
from_remote: '该模型并非通过 1Panel 下载,无相关拉取日志。',
no_logs: '该模型的拉取日志已被删除,无法查看相关日志。',
vllmVersionHelper: 'FusionXpark GB 10 服务器请选择 -cu130 版本',
- ascendVisibleDevices: 'Ascend 可见设备',
vllmCommandPortHelper: '启动命令必须使用 {0} 端口,否则服务将无法访问。',
ascendVisibleDevices: 'Ascend 可见设备(ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: '同步到模型账号',
@@ -1097,7 +1096,6 @@ const message = {
cachedToken: '缓存 Token',
cacheHitRate: '缓存命中率',
activeUsers: '活跃用户',
- activeStreamingRequests: '活跃流式请求',
activeModels: '活跃模型',
failedRequests: '失败请求',
averageTokenPerRequest: '平均 Token/请求',
@@ -6286,6 +6284,55 @@ const message = {
barkConfigHelper: 'Bark 告警通知配置',
webhookName: '机器人名称',
webhookUrl: 'Webhook 地址',
+ custom: 'Webhook',
+ webhookPreset: '预设',
+ genericJsonPreset: '通用 JSON',
+ customPreset: '自定义',
+ webhookUrlSecretHelper: 'Webhook 地址加密存储,可在此查看和编辑',
+ webhookPublicAddressHelper: '仅支持公网可访问的 HTTP/HTTPS 地址;本机、内网和保留地址会被拦截',
+ customWebhookRecoveryRequired:
+ '当前配置已失效或来自旧版本,请重新填写 Webhook 地址及所需的敏感 Header 后保存',
+ clearSecret: '清除',
+ keepSecret: '保持不变',
+ secretCleared: '保存后将清除此敏感值',
+ bodyType: 'Body 类型',
+ bodyTemplate: 'Body 模板',
+ formFieldName: '字段名',
+ formFieldValue: '字段值',
+ addFormField: '添加字段',
+ webhookAdvanced: '高级配置',
+ headers: 'Headers',
+ headerName: 'Header 名称',
+ headerValue: 'Header 值',
+ secretValue: '敏感值',
+ addHeader: '添加 Header',
+ templateVariables: '模板变量',
+ templateVariableTitle: '告警标题',
+ templateVariableMessage: '告警内容',
+ templateVariableType: '告警类型',
+ templateVariableNodeName: '节点名称',
+ templateVariableTimestamp: '发生时间',
+ templateVariablesHelper:
+ 'title=告警标题,message=告警内容,type=告警类型,nodeName=节点名称,timestamp=发生时间;点击变量可插入 Body。',
+ testResultStale: '配置已更改,之前的测试结果已失效',
+ alertConfigChanged: '配置已更新,请刷新后重试',
+ presetOverwriteHelper: '切换预设将覆盖当前 Body 配置,是否继续?',
+ customWebhookValidation: {
+ displayNameRequired: '请输入显示名称',
+ urlRequired: '请输入 Webhook 地址',
+ urlInvalid: 'Webhook 地址必须是有效的 HTTP 或 HTTPS 地址',
+ bodyRequired: '请配置请求 Body',
+ jsonInvalid: 'JSON Body 模板格式无效',
+ formFieldRequired: 'Form 字段名不能为空',
+ formFieldDuplicate: 'Form 字段名不能重复',
+ headerRequired: 'Header 名称不能为空',
+ headerInvalid: 'Header 名称格式无效',
+ headerDuplicate: 'Header 名称不能重复',
+ headerReserved: '该 Header 由系统管理,不能自定义',
+ headerMustBeSecret: '认证或凭证类 Header 必须设为敏感值',
+ templateVariableInvalid: 'Body 中包含不支持的模板变量',
+ secretRequired: '请输入敏感值,或选择保持不变/清除',
+ },
alertConfigProHelper: '商业版额外支持企业微信、钉钉、飞书及短信告警。',
recipientPlaceholder: '请输入收件人邮箱地址',
addRecipient: '添加收件人',
diff --git a/frontend/src/views/cronjob/cronjob/operate/index.vue b/frontend/src/views/cronjob/cronjob/operate/index.vue
index 131f25f84..96de8f0ef 100644
--- a/frontend/src/views/cronjob/cronjob/operate/index.vue
+++ b/frontend/src/views/cronjob/cronjob/operate/index.vue
@@ -870,6 +870,7 @@ import { splitTimeFromSecond, transferTimeToSecond } from '@/utils/validate';
import { getGroupList } from '@/api/modules/group';
import { routerToName, routerToPath } from '@/utils/router';
import { loadBaseDir } from '@/api/modules/setting';
+import { getAlertConfigDisplayName } from '@/views/setting/alert/setting/drawer/secret-field';
const router = useRouter();
const { docsUrl, isFxplay, isProductPro } = useGlobalStore();
@@ -914,6 +915,8 @@ const legacyAlertMethodTypeMap: Record = {
weCom: 'weCom',
dingTalk: 'dingTalk',
feiShu: 'feiShu',
+ webhook: 'custom',
+ custom: 'custom',
};
const normalizeAlertMethodItems = (methods: string[]) => {
@@ -935,7 +938,7 @@ const groupedAlertConfigOptions = computed(() => {
type: string;
options: { value: string; label: string; typeLabel: string }[];
}[] = [];
- const typeOrder = ['email', 'sms', 'weCom', 'dingTalk', 'feiShu', 'bark'];
+ const typeOrder = ['email', 'sms', 'weCom', 'dingTalk', 'feiShu', 'bark', 'custom'];
for (const t of typeOrder) {
if (typeMap.has(t)) {
const typeLabel = getConfigTypeLabel(t);
@@ -948,6 +951,14 @@ const groupedAlertConfigOptions = computed(() => {
});
}
}
+ for (const [type, options] of typeMap) {
+ if (typeOrder.includes(type)) continue;
+ const typeLabel = getConfigTypeLabel(type);
+ groups.push({
+ type,
+ options: options.map((item) => ({ ...item, typeLabel })),
+ });
+ }
return groups;
});
@@ -957,8 +968,11 @@ const getConfigTypeLabel = (type: string): string => {
const getAlertConfigOptionLabel = (c: Alert.AlertConfigInfo): string => {
try {
- const cfg = JSON.parse(c.config || '{}');
- return cfg.displayName || i18n.global.t(`xpack.alert.${c.type === 'email' ? 'mail' : c.type}`);
+ const cfg = JSON.parse(c.config || '{}') as Record;
+ return (
+ getAlertConfigDisplayName(c.type, cfg) ||
+ i18n.global.t(`xpack.alert.${c.type === 'email' ? 'mail' : c.type}`)
+ );
} catch {
return i18n.global.t(`xpack.alert.${c.type === 'email' ? 'mail' : c.type}`);
}
diff --git a/frontend/src/views/setting/alert/dash/index.vue b/frontend/src/views/setting/alert/dash/index.vue
index cbe1f0229..10ac7f699 100644
--- a/frontend/src/views/setting/alert/dash/index.vue
+++ b/frontend/src/views/setting/alert/dash/index.vue
@@ -160,6 +160,7 @@
+
+
diff --git a/frontend/src/views/setting/alert/setting/drawer/custom-webhook.ts b/frontend/src/views/setting/alert/setting/drawer/custom-webhook.ts
new file mode 100644
index 000000000..a36a5ab57
--- /dev/null
+++ b/frontend/src/views/setting/alert/setting/drawer/custom-webhook.ts
@@ -0,0 +1,653 @@
+export const CUSTOM_WEBHOOK_TYPE = 'custom';
+export const CUSTOM_WEBHOOK_SCHEMA_VERSION = 1 as const;
+export const CUSTOM_WEBHOOK_LIMITS = {
+ url: 8192,
+ headers: 64,
+ headerName: 256,
+ headerValue: 16 * 1024,
+ body: 256 * 1024,
+ formFields: 128,
+} as const;
+
+export type CustomWebhookPreset = 'genericJson' | 'slack' | 'discord' | 'teamsWorkflows' | 'custom';
+export type CustomWebhookBodyType = 'json' | 'form' | 'text';
+export type CustomWebhookSecretAction = 'keep' | 'replace' | 'clear';
+
+export interface CustomWebhookSecretView {
+ configured: boolean;
+ masked?: string;
+ value?: string;
+}
+
+export interface CustomWebhookSecretDraft extends CustomWebhookSecretView {
+ masked: string;
+ action: CustomWebhookSecretAction;
+ value: string;
+ originalValue?: string;
+}
+
+export interface CustomWebhookFormField {
+ uid: string;
+ key: string;
+ value: string;
+}
+
+export interface CustomWebhookFormFieldView {
+ uid?: string;
+ key: string;
+ value: string;
+}
+
+export interface CustomWebhookHeaderView {
+ uid: string;
+ key: string;
+ secret: boolean;
+ value?: string;
+ configured?: boolean;
+ masked?: string;
+}
+
+export interface CustomWebhookHeaderDraft {
+ uid: string;
+ key: string;
+ secret: boolean;
+ configured: boolean;
+ masked: string;
+ action: CustomWebhookSecretAction;
+ value: string;
+ originalValue?: string;
+}
+
+export interface CustomWebhookBody {
+ type: CustomWebhookBodyType;
+ template: string;
+ fields: CustomWebhookFormField[];
+}
+
+export interface CustomWebhookBodyView {
+ type: CustomWebhookBodyType;
+ template: string;
+ fields: CustomWebhookFormFieldView[];
+}
+
+export interface CustomWebhookConfigView {
+ schemaVersion: typeof CUSTOM_WEBHOOK_SCHEMA_VERSION;
+ state?: 'legacy' | 'invalid';
+ displayName: string;
+ preset: CustomWebhookPreset;
+ method?: 'POST';
+ url: CustomWebhookSecretView | string;
+ body: Partial;
+ headers: CustomWebhookHeaderView[];
+}
+
+export interface CustomWebhookDraft {
+ schemaVersion: typeof CUSTOM_WEBHOOK_SCHEMA_VERSION;
+ state?: 'legacy' | 'invalid';
+ displayName: string;
+ preset: CustomWebhookPreset;
+ url: CustomWebhookSecretDraft;
+ body: CustomWebhookBody;
+ headers: CustomWebhookHeaderDraft[];
+}
+
+export interface CustomWebhookSecretMutation {
+ action: CustomWebhookSecretAction;
+ value?: string;
+}
+
+export interface CustomWebhookConfigUpdate {
+ schemaVersion: typeof CUSTOM_WEBHOOK_SCHEMA_VERSION;
+ displayName: string;
+ preset: CustomWebhookPreset;
+ method: 'POST';
+ url: CustomWebhookSecretMutation;
+ body: {
+ type: CustomWebhookBodyType;
+ template: string;
+ fields: Array<{
+ key: string;
+ value: string;
+ }>;
+ };
+ headers: Array<{
+ uid: string;
+ key: string;
+ secret: boolean;
+ action: CustomWebhookSecretAction;
+ value?: string;
+ }>;
+}
+
+export interface CustomWebhookValidationIssue {
+ field: string;
+ code:
+ | 'displayNameRequired'
+ | 'urlRequired'
+ | 'urlInvalid'
+ | 'bodyRequired'
+ | 'jsonInvalid'
+ | 'formFieldRequired'
+ | 'formFieldDuplicate'
+ | 'headerRequired'
+ | 'headerInvalid'
+ | 'headerDuplicate'
+ | 'headerReserved'
+ | 'headerMustBeSecret'
+ | 'templateVariableInvalid'
+ | 'secretRequired';
+}
+
+interface PresetDefinition {
+ bodyType: CustomWebhookBodyType;
+ template: string;
+}
+
+const PRESET_DEFINITIONS: Record, PresetDefinition> = {
+ genericJson: {
+ bodyType: 'json',
+ template: `{
+ "schema_version": "1",
+ "title": "{{title}}",
+ "message": "{{message}}",
+ "type": "{{type}}",
+ "node_name": "{{nodeName}}",
+ "timestamp": "{{timestamp}}"
+}`,
+ },
+ slack: {
+ bodyType: 'json',
+ template: `{
+ "text": "*{{title}}*\\n{{message}}"
+}`,
+ },
+ discord: {
+ bodyType: 'json',
+ template: `{
+ "content": "**{{title}}**\\n{{message}}",
+ "allowed_mentions": { "parse": [] }
+}`,
+ },
+ teamsWorkflows: {
+ bodyType: 'json',
+ template: `{
+ "type": "message",
+ "attachments": [
+ {
+ "contentType": "application/vnd.microsoft.card.adaptive",
+ "contentUrl": null,
+ "content": {
+ "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
+ "type": "AdaptiveCard",
+ "version": "1.4",
+ "body": [
+ { "type": "TextBlock", "weight": "Bolder", "text": "{{title}}" },
+ { "type": "TextBlock", "wrap": true, "text": "{{message}}" }
+ ]
+ }
+ }
+ ]
+}`,
+ },
+};
+
+export const CUSTOM_WEBHOOK_VARIABLES = ['{{title}}', '{{message}}', '{{type}}', '{{nodeName}}', '{{timestamp}}'];
+
+const RESERVED_HEADERS = new Set([
+ 'connection',
+ 'content-length',
+ 'content-type',
+ 'host',
+ 'proxy-connection',
+ 'proxy-authorization',
+ 'te',
+ 'trailer',
+ 'transfer-encoding',
+ 'upgrade',
+]);
+
+const CUSTOM_WEBHOOK_VARIABLE_TOKENS = new Set(CUSTOM_WEBHOOK_VARIABLES);
+
+const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
+
+export const contentTypeForBodyType = (type: CustomWebhookBodyType): string => {
+ if (type === 'form') return 'application/x-www-form-urlencoded';
+ if (type === 'text') return 'text/plain';
+ return 'application/json';
+};
+
+export const createCustomWebhookUid = (): string => {
+ if (typeof globalThis.crypto?.randomUUID === 'function') {
+ return globalThis.crypto.randomUUID();
+ }
+ const bytes = new Uint8Array(16);
+ if (typeof globalThis.crypto?.getRandomValues === 'function') {
+ globalThis.crypto.getRandomValues(bytes);
+ } else {
+ for (let index = 0; index < bytes.length; index += 1) {
+ bytes[index] = Math.floor(Math.random() * 256);
+ }
+ }
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
+};
+
+export const isCustomWebhookSecretHeader = (key: string): boolean => {
+ const normalized = key.trim().toLowerCase();
+ const compact = normalized.replace(/[-_]/g, '');
+ return (
+ normalized === 'authorization' ||
+ normalized === 'cookie' ||
+ normalized.endsWith('-key') ||
+ normalized.endsWith('_key') ||
+ compact.includes('apikey') ||
+ compact.includes('token') ||
+ compact.includes('secret') ||
+ compact.includes('signature')
+ );
+};
+
+export const createCustomWebhookFormField = (): CustomWebhookFormField => ({
+ uid: createCustomWebhookUid(),
+ key: '',
+ value: '',
+});
+
+export const createCustomWebhookHeader = (): CustomWebhookHeaderDraft => ({
+ uid: createCustomWebhookUid(),
+ key: '',
+ secret: false,
+ configured: false,
+ masked: '',
+ action: 'replace',
+ value: '',
+});
+
+export const createDefaultCustomWebhookDraft = (): CustomWebhookDraft => {
+ const draft: CustomWebhookDraft = {
+ schemaVersion: CUSTOM_WEBHOOK_SCHEMA_VERSION,
+ displayName: '',
+ preset: 'genericJson',
+ url: {
+ configured: false,
+ masked: '',
+ action: 'replace',
+ value: '',
+ },
+ body: {
+ type: 'json',
+ template: '',
+ fields: [],
+ },
+ headers: [],
+ };
+ return applyCustomWebhookPreset(draft, 'genericJson');
+};
+
+const normalizePreset = (value: unknown): CustomWebhookPreset => {
+ if (value === 'genericJson' || value === 'slack' || value === 'discord' || value === 'teamsWorkflows') {
+ return value;
+ }
+ return 'custom';
+};
+
+const normalizeBodyType = (value: unknown): CustomWebhookBodyType => {
+ if (value === 'form' || value === 'text') return value;
+ return 'json';
+};
+
+export const maskCustomWebhookUrl = (value: string): string => {
+ return value ? '******' : '';
+};
+
+export const hydrateCustomWebhookDraft = (raw: Partial = {}): CustomWebhookDraft => {
+ const fallback = createDefaultCustomWebhookDraft();
+ const preset = normalizePreset(raw.preset);
+ const rawUrl = raw.url;
+ const hasSanitizedUrlView = typeof rawUrl === 'object' && rawUrl !== null;
+ const urlConfigured = typeof rawUrl === 'string' ? Boolean(rawUrl) : Boolean(rawUrl?.configured);
+ const urlValue = typeof rawUrl === 'string' ? rawUrl : '';
+ const urlMasked = typeof rawUrl === 'string' ? '' : rawUrl?.masked || '';
+ const bodyType = normalizeBodyType(raw.body?.type);
+ const headers = Array.isArray(raw.headers)
+ ? raw.headers.map((header) => {
+ const secret = Boolean(header.secret) || isCustomWebhookSecretHeader(header.key || '');
+ const secretValue = secret && typeof header.value === 'string' ? header.value : '';
+ const configured = secret ? Boolean(header.configured ?? secretValue) : false;
+ return {
+ uid: header.uid || createCustomWebhookUid(),
+ key: header.key || '',
+ secret,
+ configured,
+ masked: secret ? header.masked || (header.value ? '***' : '') : '',
+ action: secret
+ ? secretValue
+ ? ('keep' as const)
+ : configured
+ ? ('keep' as const)
+ : ('clear' as const)
+ : ('replace' as const),
+ value: secret ? secretValue : header.value || '',
+ ...(secretValue ? { originalValue: secretValue } : {}),
+ };
+ })
+ : [];
+
+ return {
+ schemaVersion: CUSTOM_WEBHOOK_SCHEMA_VERSION,
+ ...(raw.state === 'legacy' || raw.state === 'invalid' ? { state: raw.state } : {}),
+ displayName: raw.displayName || '',
+ preset,
+ url: {
+ configured: urlConfigured,
+ masked: urlMasked,
+ action:
+ typeof rawUrl === 'string' && rawUrl
+ ? 'keep'
+ : urlConfigured
+ ? 'keep'
+ : hasSanitizedUrlView
+ ? 'clear'
+ : 'replace',
+ value: urlValue,
+ ...(urlValue ? { originalValue: urlValue } : {}),
+ },
+ body: {
+ type: bodyType,
+ template:
+ bodyType === 'form'
+ ? ''
+ : raw.body?.template === undefined
+ ? bodyType === 'text'
+ ? '{{message}}'
+ : fallback.body.template
+ : raw.body.template,
+ fields: Array.isArray(raw.body?.fields)
+ ? raw.body.fields.map((field) => ({
+ uid: field.uid || createCustomWebhookUid(),
+ key: field.key || '',
+ value: field.value || '',
+ }))
+ : [],
+ },
+ headers,
+ };
+};
+
+export const applyCustomWebhookPreset = (
+ source: CustomWebhookDraft,
+ preset: CustomWebhookPreset,
+): CustomWebhookDraft => {
+ if (preset === 'custom') {
+ return { ...source, preset: 'custom' };
+ }
+ const definition = PRESET_DEFINITIONS[preset];
+ return {
+ ...source,
+ preset,
+ body: {
+ type: definition.bodyType,
+ template: definition.template,
+ fields: [],
+ },
+ };
+};
+
+export const isCustomWebhookPresetPristine = (draft: CustomWebhookDraft): boolean => {
+ if (draft.preset === 'custom') return false;
+ const expected = applyCustomWebhookPreset(draft, draft.preset);
+ return (
+ draft.body.type === expected.body.type &&
+ draft.body.template === expected.body.template &&
+ draft.body.fields.length === expected.body.fields.length
+ );
+};
+
+export const updateCustomWebhookBodyTemplate = (draft: CustomWebhookDraft, template: string): CustomWebhookDraft => ({
+ ...draft,
+ body: { ...draft.body, template },
+});
+
+const serializeSecret = (secret: CustomWebhookSecretDraft): CustomWebhookSecretMutation => {
+ if (secret.action === 'replace') {
+ return { action: 'replace', value: secret.value.trim() };
+ }
+ return { action: secret.action };
+};
+
+export const serializeCustomWebhookDraft = (draft: CustomWebhookDraft): CustomWebhookConfigUpdate => ({
+ schemaVersion: CUSTOM_WEBHOOK_SCHEMA_VERSION,
+ displayName: draft.displayName.trim(),
+ preset: draft.preset,
+ method: 'POST',
+ url: serializeSecret(draft.url),
+ body: {
+ type: draft.body.type,
+ template: draft.body.type === 'form' ? '' : draft.body.template,
+ fields:
+ draft.body.type === 'form'
+ ? draft.body.fields.map((field) => ({
+ key: field.key.trim(),
+ value: field.value,
+ }))
+ : [],
+ },
+ headers: draft.headers.map((header) => ({
+ uid: header.uid,
+ key: header.key.trim(),
+ secret: header.secret,
+ action: header.secret ? header.action : 'replace',
+ ...(header.secret && header.action !== 'replace' ? {} : { value: header.value }),
+ })),
+});
+
+const isValidHttpUrl = (value: string): boolean => {
+ try {
+ const parsed = new URL(value);
+ return (
+ (parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
+ !parsed.username &&
+ !parsed.password &&
+ !parsed.hash
+ );
+ } catch {
+ return false;
+ }
+};
+
+const isJsonTemplateValid = (value: string): boolean => {
+ try {
+ JSON.parse(value);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+const hasTemplateJsonKey = (value: string): boolean => {
+ try {
+ const visit = (node: unknown): boolean => {
+ if (Array.isArray(node)) return node.some(visit);
+ if (node && typeof node === 'object') {
+ return Object.entries(node).some(
+ ([key, child]) => key.includes('{{') || key.includes('}}') || visit(child),
+ );
+ }
+ return false;
+ };
+ return visit(JSON.parse(value));
+ } catch {
+ return false;
+ }
+};
+
+const hasInvalidTemplateVariable = (value: string): boolean => {
+ let remainder = value;
+ while (remainder) {
+ const start = remainder.indexOf('{{');
+ if (start < 0) return remainder.includes('}}');
+ if (remainder.slice(0, start).includes('}}')) return true;
+ const endOffset = remainder.slice(start + 2).indexOf('}}');
+ if (endOffset < 0) return true;
+ const end = start + 2 + endOffset + 2;
+ if (!CUSTOM_WEBHOOK_VARIABLE_TOKENS.has(remainder.slice(start, end))) return true;
+ remainder = remainder.slice(end);
+ }
+ return false;
+};
+
+export interface CustomWebhookTemplateSelection {
+ start: number;
+ end: number;
+}
+
+export interface CustomWebhookTemplateInsertion {
+ template: string;
+ cursor: number;
+}
+
+export const insertCustomWebhookVariable = (
+ template: string,
+ variable: string,
+ bodyType: Exclude,
+ selection?: CustomWebhookTemplateSelection,
+): CustomWebhookTemplateInsertion => {
+ if (selection && selection.start >= 0 && selection.end >= selection.start && selection.end <= template.length) {
+ const next = template.slice(0, selection.start) + variable + template.slice(selection.end);
+ return { template: next, cursor: selection.start + variable.length };
+ }
+ if (bodyType === 'text') {
+ return { template: template + variable, cursor: template.length + variable.length };
+ }
+
+ const stringValuePattern = /("(?:message|text|content)"\s*:\s*")((?:\\.|[^"\\])*)(")/;
+ const anyStringValuePattern = /(:\s*")((?:\\.|[^"\\])*)(")/;
+ const match = stringValuePattern.exec(template) || anyStringValuePattern.exec(template);
+ if (match) {
+ const insertAt = match.index + match[0].length - 1;
+ return {
+ template: template.slice(0, insertAt) + variable + template.slice(insertAt),
+ cursor: insertAt + variable.length,
+ };
+ }
+
+ const trimmed = template.trim();
+ if (trimmed === '{}') {
+ const next = `{\n "message": "${variable}"\n}`;
+ return { template: next, cursor: next.indexOf(variable) + variable.length };
+ }
+ if (trimmed === '[]') {
+ const next = `[\n "${variable}"\n]`;
+ return { template: next, cursor: next.indexOf(variable) + variable.length };
+ }
+ return { template, cursor: template.length };
+};
+
+export const validateCustomWebhookDraft = (
+ draft: CustomWebhookDraft,
+ options: { allowClearedUrl?: boolean } = {},
+): CustomWebhookValidationIssue[] => {
+ const issues: CustomWebhookValidationIssue[] = [];
+ if (!draft.displayName.trim()) {
+ issues.push({ field: 'displayName', code: 'displayNameRequired' });
+ }
+ if (draft.url.action === 'keep') {
+ if (!draft.url.configured) issues.push({ field: 'url', code: 'urlRequired' });
+ } else if (draft.url.action === 'replace') {
+ if (!draft.url.value.trim()) {
+ issues.push({ field: 'url', code: 'urlRequired' });
+ } else if (!isValidHttpUrl(draft.url.value.trim())) {
+ issues.push({ field: 'url', code: 'urlInvalid' });
+ }
+ } else if (!options.allowClearedUrl) {
+ issues.push({ field: 'url', code: 'urlRequired' });
+ }
+
+ if (draft.body.type === 'form') {
+ if (draft.body.fields.length === 0) {
+ issues.push({ field: 'body', code: 'bodyRequired' });
+ }
+ const keys = new Set();
+ draft.body.fields.forEach((field, index) => {
+ const key = field.key.trim();
+ if (!key) {
+ issues.push({ field: `body.fields.${index}`, code: 'formFieldRequired' });
+ return;
+ }
+ if (key.includes('{{') || key.includes('}}')) {
+ issues.push({ field: `body.fields.${index}`, code: 'templateVariableInvalid' });
+ }
+ const normalized = key.toLowerCase();
+ if (keys.has(normalized)) {
+ issues.push({ field: `body.fields.${index}`, code: 'formFieldDuplicate' });
+ }
+ keys.add(normalized);
+ if (hasInvalidTemplateVariable(field.value)) {
+ issues.push({ field: `body.fields.${index}`, code: 'templateVariableInvalid' });
+ }
+ });
+ } else if (!draft.body.template.trim()) {
+ issues.push({ field: 'body', code: 'bodyRequired' });
+ } else if (draft.body.type === 'json' && !isJsonTemplateValid(draft.body.template)) {
+ issues.push({ field: 'body', code: 'jsonInvalid' });
+ } else if (draft.body.type === 'json' && hasTemplateJsonKey(draft.body.template)) {
+ issues.push({ field: 'body', code: 'templateVariableInvalid' });
+ }
+ if (draft.body.type !== 'form' && hasInvalidTemplateVariable(draft.body.template)) {
+ issues.push({ field: 'body', code: 'templateVariableInvalid' });
+ }
+
+ const headerKeys = new Set();
+ draft.headers.forEach((header, index) => {
+ const key = header.key.trim();
+ const normalized = key.toLowerCase();
+ if (!key) {
+ issues.push({ field: `headers.${index}`, code: 'headerRequired' });
+ } else if (!HEADER_NAME_PATTERN.test(key)) {
+ issues.push({ field: `headers.${index}`, code: 'headerInvalid' });
+ } else if (RESERVED_HEADERS.has(normalized)) {
+ issues.push({ field: `headers.${index}`, code: 'headerReserved' });
+ } else if (headerKeys.has(normalized)) {
+ issues.push({ field: `headers.${index}`, code: 'headerDuplicate' });
+ }
+ headerKeys.add(normalized);
+
+ if (isCustomWebhookSecretHeader(key) && !header.secret) {
+ issues.push({ field: `headers.${index}`, code: 'headerMustBeSecret' });
+ }
+
+ if (header.secret) {
+ if (header.action === 'keep' && !header.configured) {
+ issues.push({ field: `headers.${index}`, code: 'secretRequired' });
+ }
+ if (header.action === 'replace' && !header.value) {
+ issues.push({ field: `headers.${index}`, code: 'secretRequired' });
+ }
+ }
+ });
+ return issues;
+};
+
+export const formatCustomWebhookSafeSummary = (
+ config: Partial,
+ options: { includeUrl?: boolean } = {},
+): string => {
+ const bodyType = normalizeBodyType(config.body?.type).toUpperCase();
+ const headerCount = Array.isArray(config.headers) ? config.headers.length : 0;
+ const summary = `POST · ${bodyType} · ${headerCount} Headers`;
+ if (options.includeUrl === false) return summary;
+ const url = typeof config.url === 'string' ? maskCustomWebhookUrl(config.url) : config.url?.masked || '***';
+ return `${summary} · ${url}`;
+};
+
+export const formatCustomWebhookDetails = (
+ config: Partial | string,
+ missingUrl = '-',
+): string => {
+ const normalized = typeof config === 'string' ? { url: config } : config;
+ const summary = formatCustomWebhookSafeSummary(normalized, { includeUrl: false });
+ const url = typeof normalized.url === 'string' ? normalized.url : normalized.url?.value || '';
+ return `${summary} · ${url || missingUrl}`;
+};
diff --git a/frontend/src/views/setting/alert/setting/drawer/index.vue b/frontend/src/views/setting/alert/setting/drawer/index.vue
index be9ee76f6..5debe49af 100644
--- a/frontend/src/views/setting/alert/setting/drawer/index.vue
+++ b/frontend/src/views/setting/alert/setting/drawer/index.vue
@@ -1,5 +1,5 @@
-
+
-
+
{{ $t('xpack.alert.userNameHelper') }}
-
-
+
+
{{ $t('xpack.alert.passwordHelper') }}
@@ -90,6 +90,13 @@
+
+
@@ -107,7 +114,7 @@
-
+
{{ $t('xpack.alert.test') }}
@@ -122,6 +129,22 @@
+
{{ $t('commons.button.cancel') }}
@@ -135,16 +158,25 @@
+
+
diff --git a/frontend/src/views/setting/alert/setting/drawer/secret-field.ts b/frontend/src/views/setting/alert/setting/drawer/secret-field.ts
new file mode 100644
index 000000000..91610fbc6
--- /dev/null
+++ b/frontend/src/views/setting/alert/setting/drawer/secret-field.ts
@@ -0,0 +1,100 @@
+export type SecretAction = 'keep' | 'replace' | 'clear';
+
+export interface SecretView {
+ configured: boolean;
+ masked?: string;
+}
+
+export interface SecretDraft extends SecretView {
+ masked: string;
+ action: SecretAction;
+ value: string;
+ originalValue?: string;
+}
+
+export const secretEditorValue = (draft: SecretDraft): string => {
+ if (draft.action === 'keep' || draft.action === 'replace') return draft.value;
+ return '';
+};
+
+export const secretEditorPlaceholder = (draft: SecretDraft, fallback = ''): string => {
+ if (draft.action === 'keep' && draft.configured) return draft.masked || '******';
+ return fallback;
+};
+
+export const replaceSecretDraft = (draft: T, value: string): T => ({
+ ...draft,
+ action: 'replace',
+ value,
+});
+
+export const keepSecretDraft = (draft: T): T => ({
+ ...draft,
+ action: 'keep',
+ value: draft.originalValue || '',
+});
+
+export const clearSecretDraft = (draft: T): T => ({
+ ...draft,
+ action: 'clear',
+ value: '',
+});
+
+export const rawSecretValue = (source: unknown): string => {
+ if (typeof source === 'string') return source;
+ if (!source || typeof source !== 'object') return '';
+ const value = (source as Record).value;
+ return typeof value === 'string' ? value : '';
+};
+
+export const serializeLegacySecretValue = (value: string, trim = false): string => (trim ? value.trim() : value);
+
+export interface LegacyEmailTestFields {
+ host: string;
+ port: number;
+ sender: string;
+ userName: string;
+ password: string;
+ displayName: string;
+ encryption: string;
+ recipient: string;
+}
+
+const rawStringValue = (value: unknown): string => (typeof value === 'string' ? value : '');
+
+export const buildLegacyEmailTestFields = (config: Record): LegacyEmailTestFields => ({
+ host: rawStringValue(config.host),
+ port: Number(config.port) || 0,
+ sender: rawStringValue(config.sender),
+ userName: rawStringValue(config.userName),
+ password: rawSecretValue(config.password),
+ displayName: rawStringValue(config.displayName),
+ encryption: rawStringValue(config.encryption) || 'NONE',
+ recipient: rawStringValue(config.recipient),
+});
+
+export const getAlertConfigDisplayName = (type: string, config: Record): string => {
+ const displayName = typeof config.displayName === 'string' ? config.displayName.trim() : '';
+ if (displayName) return displayName;
+
+ const sender = typeof config.sender === 'string' ? config.sender.trim() : '';
+ if (sender) return sender;
+
+ if (type === 'sms') {
+ const phone = rawSecretValue(config.phone);
+ if (phone) return phone;
+ }
+
+ if (Array.isArray(config.webhooks)) {
+ return config.webhooks
+ .map((webhook) =>
+ webhook && typeof webhook === 'object' && typeof webhook.displayName === 'string'
+ ? webhook.displayName.trim()
+ : '',
+ )
+ .filter(Boolean)
+ .join(', ');
+ }
+
+ return '';
+};
diff --git a/frontend/src/views/setting/alert/setting/index.vue b/frontend/src/views/setting/alert/setting/index.vue
index 005ae8ada..ae2f8ea82 100644
--- a/frontend/src/views/setting/alert/setting/index.vue
+++ b/frontend/src/views/setting/alert/setting/index.vue
@@ -49,7 +49,7 @@
{{ $t('xpack.alert.alertConfigHelper') }}
-
+
{{ $t('commons.units.semicolon') }}{{ $t('xpack.alert.alertConfigProHelper') }}
-
-
- {{ getConfigDetails(row) }}
-
-
-
- {{ getConfigSummary(row) }}
-
-
+
+
+ {{ expandedIds.has(row.id!) ? getConfigDetails(row) : getConfigSummary(row) }}
+
+
+
+
+
@@ -107,7 +116,7 @@
v-model="row.status"
active-value="Enable"
inactive-value="Disable"
- :disabled="!isProductPro && ['weCom', 'dingTalk', 'feiShu', 'sms'].includes(row.type)"
+ :disabled="isStatusChangeDisabled(row)"
@change="onStatusChange(row)"
/>
@@ -142,14 +151,22 @@