feat: add support for custom webhook configuration (#13685)

This commit is contained in:
2026-09-02 14:49:20 +08:00
committed by GitHub
parent 0ee93774d5
commit 5aec466c8e
54 changed files with 6430 additions and 284 deletions
+40
View File
@@ -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)
+29 -16
View File
@@ -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"`
+80
View File
@@ -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"`
}
+27 -10
View File
@@ -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 {
+217 -9
View File
@@ -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 {
+329 -29
View File
@@ -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
+26 -6
View File
@@ -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)
}
}
}
+45 -6
View File
@@ -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) {
+1 -1
View File
@@ -5787,4 +5787,4 @@
"formatZH": "从主节点同步设置",
"formatEN": "sync settings from master"
}
}
}
+15 -6
View File
@@ -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()
}
+100 -3
View File
@@ -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,
}
}
+1
View File
@@ -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)
+111
View File
@@ -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),
+206
View File
@@ -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
}
+312
View File
@@ -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
}
}
+25 -6
View File
@@ -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)
}
}
}
File diff suppressed because it is too large Load Diff
+185
View File
@@ -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
}
+767
View File
@@ -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")
}
}
@@ -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",
"<br/>", "\n",
"<br />", "\n",
"<br>", "\n",
"</p>", "\n",
"<p>", "",
"</div>", "\n",
"<div>", "",
"&nbsp;", " ",
)
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
}
+57
View File
@@ -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
}
+34
View File
@@ -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
}
+17
View File
@@ -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)
}
+6 -4
View File
@@ -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:"-"`
}
+1 -1
View File
@@ -5787,4 +5787,4 @@
"formatZH": "从主节点同步设置",
"formatEN": "sync settings from master"
}
}
}
+15
View File
@@ -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 {
+135
View File
@@ -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
}
@@ -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)
}
+1 -1
View File
@@ -206,7 +206,7 @@ class RequestHttp {
return this.service.delete(url, { params, ..._object });
}
download<BlobPart>(url: string, params?: object, _object = {}): Promise<BlobPart> {
return this.service.post(url, params, _object);
return this.service.post(url, params, _object) as unknown as Promise<BlobPart>;
}
upload<T>(url: string, params: object = {}, config?: RequestConfig): Promise<ResultData<T>> {
return this.service.post(url, params, config);
+24 -1
View File
@@ -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;
+9
View File
@@ -14,6 +14,7 @@ const resolveAlertConfigExcludeTypes = (excludeTypes: string[] = []) => {
}
return Array.from(types);
};
export const SearchAlerts = (req: Alert.AlertSearch, currentNode?: string) => {
return http.post<ResPage<Alert.AlertInfo>>(
`/alert/search`,
@@ -99,10 +100,18 @@ export const UpdateAlertConfig = (req: Alert.AlertConfigUpdateReq) => {
return http.post<any>(`/alert/config/update`, req);
};
export const UpdateAlertConfigStatus = (req: Alert.AlertConfigStatusReq) => {
return http.post<any>(`/alert/config/status`, req);
};
export const TestAlertConfig = (req: Alert.AlertConfigTest) => {
return http.post<any>(`/alert/config/test`, req);
};
export const TestCustomAlertConfig = (req: Alert.AlertConfigCustomTest) => {
return http.post<Alert.AlertConfigCustomTestResult>(`/alert/config/test`, req);
};
export const SyncAlertInfo = (req: Alert.AlertLogId) => {
return http.post<any>(`/xpack/alert/logs/sync`, req);
};
+1 -1
View File
@@ -199,6 +199,6 @@ defineExpose({
border: 1px solid var(--el-color-warning);
background-color: transparent;
padding: 8px 8px;
width: 70px;
width: 71px;
}
</style>
+51 -3
View File
@@ -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',
+52 -3
View File
@@ -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',
+51 -3
View File
@@ -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: 'وب‌هوک',
+51 -3
View File
@@ -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: '商用版ではWeComDingTalkFeishuSMS 通知も利用できます',
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',
+51 -3
View File
@@ -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',
+51 -3
View File
@@ -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',
+51 -3
View File
@@ -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',
+51 -4
View File
@@ -954,7 +954,6 @@ const message = {
from_remote: 'Este modelo não foi baixado pelo 1Panel; não 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 ',
templateVariableTimestamp: 'Hora do evento',
templateVariablesHelper:
'title=título do alerta, message=conteúdo, type=tipo, nodeName=nome do , 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',
+52 -4
View File
@@ -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',
+52 -3
View File
@@ -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',
+50 -3
View File
@@ -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',
+49 -2
View File
@@ -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: '添加收件人',
@@ -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<string, string> = {
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<string, unknown>;
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}`);
}
@@ -160,6 +160,7 @@
<script lang="ts" setup>
import { onMounted, reactive, ref } from 'vue';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { getAlertConfigDisplayName } from '@/views/setting/alert/setting/drawer/secret-field';
import { MsgSuccess } from '@/utils/message';
import i18n from '@/lang';
import { ElMessageBox } from 'element-plus';
@@ -293,8 +294,8 @@ const formatMethod = (row: Alert.AlertInfo) => {
if (config) {
const typeLabel = i18n.global.t(`xpack.alert.${config.type === 'email' ? 'mail' : config.type}`);
try {
const cfg = JSON.parse(config.config || '{}');
const name = cfg.displayName || cfg.sender || cfg.phone || '';
const cfg = JSON.parse(config.config || '{}') as Record<string, unknown>;
const name = getAlertConfigDisplayName(config.type, cfg);
return name ? `${name}(${typeLabel})` : typeLabel;
} catch {
return typeLabel;
@@ -397,6 +397,7 @@ import i18n from '@/lang';
import { routerToName } from '@/utils/router';
import { checkCidr, checkCidrV6, checkIpV4V6 } from '@/utils/validate';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { getAlertConfigDisplayName } from '@/views/setting/alert/setting/drawer/secret-field';
const { isMaster, isProductPro, isEE, isIntl } = useGlobalStore();
@@ -442,6 +443,8 @@ const legacyMethodTypeMap: Record<string, string> = {
weCom: 'weCom',
dingTalk: 'dingTalk',
feiShu: 'feiShu',
webhook: 'custom',
custom: 'custom',
};
const normalizeMethodValues = (methods: string[]) => {
@@ -467,23 +470,8 @@ const getConfigTypeLabel = (type: string): string => {
const getConfigOptionLabel = (c: Alert.AlertConfigInfo): string => {
try {
const cfg = JSON.parse(c.config || '{}');
const name = cfg.displayName || cfg.sender || cfg.phone || '';
if (c.type === 'email') {
return name ? `${name}` : cfg.sender || getConfigTypeLabel(c.type);
}
if (cfg.webhooks && cfg.webhooks.length > 0) {
return cfg.webhooks
.map((w: { displayName: string }) => w.displayName || '')
.filter(Boolean)
.join(', ');
}
if (cfg.displayName) {
return cfg.displayName;
}
if (c.type === 'sms') {
return cfg.phone || getConfigTypeLabel(c.type);
}
const cfg = JSON.parse(c.config || '{}') as Record<string, unknown>;
return getAlertConfigDisplayName(c.type, cfg) || getConfigTypeLabel(c.type);
} catch {}
return getConfigTypeLabel(c.type);
};
@@ -98,6 +98,7 @@ import {
} from '@/api/modules/alert';
import { ElMessageBox } from 'element-plus';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { getAlertConfigDisplayName } from '@/views/setting/alert/setting/drawer/secret-field';
const { isMobile, isProductPro, isIntl, isMaster } = useGlobalStore();
const { t } = i18n.global;
@@ -242,8 +243,8 @@ const formatMethod = (row: Alert.AlertLog) => {
const typeKey = config.type === 'email' ? 'mail' : config.type;
const typeLabel = i18n.global.t('xpack.alert.' + typeKey);
try {
const cfg = JSON.parse(config.config || '{}');
const name = cfg.displayName || cfg.sender || cfg.phone || '';
const cfg = JSON.parse(config.config || '{}') as Record<string, unknown>;
const name = getAlertConfigDisplayName(config.type, cfg);
return name ? `${name}(${typeLabel})` : typeLabel;
} catch {
return typeLabel;
@@ -268,7 +269,8 @@ const formatMethod = (row: Alert.AlertLog) => {
case 'sms':
return t('xpack.alert.sms');
case 'webhook':
return t('xpack.alert.webhookItem');
case 'custom':
return t('xpack.alert.custom');
case 'bark':
return t('xpack.alert.bark');
default:
@@ -0,0 +1,678 @@
<template>
<div class="custom-webhook-form">
<el-alert
v-if="modelValue.state"
class="mb-3"
:closable="false"
type="warning"
show-icon
:title="$t('xpack.alert.customWebhookRecoveryRequired')"
/>
<el-form-item
:label="$t('xpack.alert.displayName')"
prop="customWebhook.displayName"
:error="errorFor('displayName')"
>
<el-input
:model-value="modelValue.displayName"
maxlength="64"
show-word-limit
@update:model-value="updateDisplayName"
/>
</el-form-item>
<el-form-item :label="$t('xpack.alert.webhookPreset')">
<el-select :model-value="modelValue.preset" class="w-full" @change="changePreset">
<el-option value="genericJson" :label="$t('xpack.alert.genericJsonPreset')" />
<el-option value="slack" label="Slack" />
<el-option value="discord" label="Discord" />
<el-option value="teamsWorkflows" label="Teams Workflows" />
<el-option value="custom" :label="$t('xpack.alert.customPreset')" />
</el-select>
</el-form-item>
<el-form-item :label="$t('xpack.alert.webhookUrl')" :error="errorFor('url')">
<template v-if="modelValue.url.action === 'keep'">
<div class="secret-editor">
<el-input
:model-value="secretEditorValue(modelValue.url)"
type="password"
show-password
autocomplete="new-password"
:maxlength="CUSTOM_WEBHOOK_LIMITS.url"
:placeholder="secretEditorPlaceholder(modelValue.url, 'https://example.com/webhook')"
@update:model-value="updateUrlValue"
/>
<div class="secret-editor__actions">
<el-button v-if="allowClearUrl" plain type="danger" @click="clearUrl">
{{ $t('xpack.alert.clearSecret') }}
</el-button>
</div>
</div>
</template>
<template v-else-if="modelValue.url.action === 'clear'">
<div class="secret-editor">
<el-alert :closable="false" type="warning" :title="$t('xpack.alert.secretCleared')" />
<el-input
model-value=""
type="password"
show-password
autocomplete="new-password"
:maxlength="CUSTOM_WEBHOOK_LIMITS.url"
placeholder="https://example.com/webhook"
@update:model-value="updateUrlValue"
/>
<div class="secret-editor__actions">
<el-button v-if="modelValue.url.configured" plain @click="keepUrl">
{{ $t('xpack.alert.keepSecret') }}
</el-button>
</div>
</div>
</template>
<template v-else>
<div class="secret-editor">
<el-input
:model-value="modelValue.url.value"
type="password"
show-password
autocomplete="new-password"
:maxlength="CUSTOM_WEBHOOK_LIMITS.url"
placeholder="https://example.com/webhook"
@update:model-value="updateUrlValue"
/>
<div v-if="modelValue.url.configured" class="secret-editor__actions">
<el-button plain @click="keepUrl">{{ $t('xpack.alert.keepSecret') }}</el-button>
<el-button v-if="allowClearUrl" plain type="danger" @click="clearUrl">
{{ $t('xpack.alert.clearSecret') }}
</el-button>
</div>
</div>
</template>
<span class="input-help">{{ $t('xpack.alert.webhookUrlSecretHelper') }}</span>
<span class="input-help">{{ $t('xpack.alert.webhookPublicAddressHelper') }}</span>
</el-form-item>
<el-form-item :label="$t('xpack.alert.bodyType')">
<el-radio-group :model-value="modelValue.body.type" class="body-type-group" @change="changeBodyType">
<el-radio-button value="json">JSON</el-radio-button>
<el-radio-button value="form">Form</el-radio-button>
<el-radio-button value="text">Text</el-radio-button>
</el-radio-group>
<span class="input-help">POST · {{ derivedContentType }}</span>
</el-form-item>
<el-form-item :label="$t('xpack.alert.bodyTemplate')" :error="errorFor('body')">
<template v-if="modelValue.body.type === 'form'">
<div class="key-value-list">
<div v-for="(field, index) in modelValue.body.fields" :key="field.uid" class="key-value-row">
<el-input
:model-value="field.key"
:maxlength="CUSTOM_WEBHOOK_LIMITS.headerName"
:placeholder="$t('xpack.alert.formFieldName')"
@update:model-value="updateFormField(index, 'key', $event)"
/>
<el-input
:model-value="field.value"
:maxlength="CUSTOM_WEBHOOK_LIMITS.headerValue"
:placeholder="$t('xpack.alert.formFieldValue')"
@focus="activeFormFieldIndex = index"
@update:model-value="updateFormField(index, 'value', $event)"
/>
<el-button plain type="danger" @click="removeFormField(index)">
{{ $t('commons.button.delete') }}
</el-button>
</div>
<el-button
plain
type="primary"
:disabled="modelValue.body.fields.length >= CUSTOM_WEBHOOK_LIMITS.formFields"
@click="addFormField"
>
{{ $t('xpack.alert.addFormField') }}
</el-button>
</div>
</template>
<el-input
v-else
ref="bodyTemplateInputRef"
:model-value="modelValue.body.template"
type="textarea"
:rows="modelValue.body.type === 'json' ? 10 : 6"
:maxlength="CUSTOM_WEBHOOK_LIMITS.body"
resize="vertical"
@focus="bodyTemplateFocused = true"
@update:model-value="updateBodyTemplate"
/>
</el-form-item>
<el-collapse v-model="activeSections" class="advanced-collapse">
<el-collapse-item name="advanced">
<template #title>
<div class="advanced-title">
<span>{{ $t('xpack.alert.webhookAdvanced') }}</span>
<span class="advanced-title__summary">
POST · {{ derivedContentType }} · {{ modelValue.headers.length }}
{{ $t('xpack.alert.headers') }}
</span>
</div>
</template>
<el-form-item :label="$t('xpack.alert.headers')" :error="errorFor('headers')">
<div class="header-list">
<div v-for="(header, index) in modelValue.headers" :key="header.uid" class="header-card">
<el-input
:model-value="header.key"
:maxlength="CUSTOM_WEBHOOK_LIMITS.headerName"
:placeholder="$t('xpack.alert.headerName')"
@update:model-value="updateHeaderKey(index, $event)"
/>
<template v-if="!header.secret">
<el-input
:model-value="header.value"
:maxlength="CUSTOM_WEBHOOK_LIMITS.headerValue"
:placeholder="$t('xpack.alert.headerValue')"
@update:model-value="updateHeader(index, { value: $event })"
/>
</template>
<template v-else-if="header.action === 'keep'">
<el-input
:model-value="secretEditorValue(header)"
:maxlength="CUSTOM_WEBHOOK_LIMITS.headerValue"
type="password"
show-password
autocomplete="new-password"
:placeholder="secretEditorPlaceholder(header, $t('xpack.alert.headerValue'))"
@update:model-value="updateSecretHeaderValue(index, $event)"
/>
</template>
<template v-else-if="header.action === 'clear'">
<div class="secret-editor">
<el-alert
:closable="false"
type="warning"
:title="$t('xpack.alert.secretCleared')"
/>
<el-input
model-value=""
:maxlength="CUSTOM_WEBHOOK_LIMITS.headerValue"
type="password"
show-password
autocomplete="new-password"
:placeholder="$t('xpack.alert.headerValue')"
@update:model-value="updateSecretHeaderValue(index, $event)"
/>
</div>
</template>
<template v-else>
<el-input
:model-value="header.value"
:maxlength="CUSTOM_WEBHOOK_LIMITS.headerValue"
type="password"
show-password
autocomplete="new-password"
:placeholder="$t('xpack.alert.headerValue')"
@update:model-value="updateHeader(index, { value: $event })"
/>
</template>
<div class="header-card__footer">
<el-checkbox
:model-value="header.secret"
:disabled="isCustomWebhookSecretHeader(header.key)"
@update:model-value="toggleHeaderSecret(index, Boolean($event))"
>
{{ $t('xpack.alert.secretValue') }}
</el-checkbox>
<div v-if="header.secret" class="header-card__secret-actions">
<el-button
v-if="header.configured && header.action !== 'keep'"
link
@click="setHeaderSecretAction(index, 'keep')"
>
{{ $t('xpack.alert.keepSecret') }}
</el-button>
<el-button
v-if="header.configured && header.action !== 'clear'"
link
type="danger"
@click="setHeaderSecretAction(index, 'clear')"
>
{{ $t('xpack.alert.clearSecret') }}
</el-button>
</div>
<el-button link type="danger" @click="removeHeader(index)">
{{ $t('commons.button.delete') }}
</el-button>
</div>
</div>
<el-button
plain
type="primary"
:disabled="modelValue.headers.length >= CUSTOM_WEBHOOK_LIMITS.headers"
@click="addHeader"
>
{{ $t('xpack.alert.addHeader') }}
</el-button>
</div>
</el-form-item>
<el-form-item :label="$t('xpack.alert.templateVariables')">
<div class="variable-list">
<el-tag
v-for="variable in templateVariables"
:key="variable.token"
class="variable-tag"
effect="plain"
:title="variable.token"
@click="insertVariable(variable.token)"
>
{{ $t(variable.labelKey) }}
</el-tag>
</div>
<span class="input-help">{{ $t('xpack.alert.templateVariablesHelper') }}</span>
</el-form-item>
</el-collapse-item>
</el-collapse>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { ElInput, ElMessageBox } from 'element-plus';
import i18n from '@/lang';
import {
CUSTOM_WEBHOOK_VARIABLES,
CUSTOM_WEBHOOK_LIMITS,
CustomWebhookBody,
CustomWebhookBodyType,
CustomWebhookDraft,
CustomWebhookPreset,
CustomWebhookSecretAction,
CustomWebhookValidationIssue,
applyCustomWebhookPreset,
contentTypeForBodyType,
createCustomWebhookFormField,
createCustomWebhookHeader,
insertCustomWebhookVariable,
isCustomWebhookSecretHeader,
isCustomWebhookPresetPristine,
updateCustomWebhookBodyTemplate,
} from './custom-webhook';
import {
clearSecretDraft,
keepSecretDraft,
replaceSecretDraft,
secretEditorPlaceholder,
secretEditorValue,
} from './secret-field';
const props = withDefaults(
defineProps<{
modelValue: CustomWebhookDraft;
validationIssues?: CustomWebhookValidationIssue[];
allowClearUrl?: boolean;
}>(),
{
validationIssues: () => [],
allowClearUrl: false,
},
);
const emit = defineEmits<{
(e: 'update:modelValue', value: CustomWebhookDraft): void;
}>();
const activeSections = ref<string[]>([]);
const derivedContentType = computed(() => contentTypeForBodyType(props.modelValue.body.type));
const bodyTemplateInputRef = ref<InstanceType<typeof ElInput>>();
const bodyTemplateFocused = ref(false);
const activeFormFieldIndex = ref<number | null>(null);
const templateVariables = [
{ token: CUSTOM_WEBHOOK_VARIABLES[0], labelKey: 'xpack.alert.templateVariableTitle' },
{ token: CUSTOM_WEBHOOK_VARIABLES[1], labelKey: 'xpack.alert.templateVariableMessage' },
{ token: CUSTOM_WEBHOOK_VARIABLES[2], labelKey: 'xpack.alert.templateVariableType' },
{ token: CUSTOM_WEBHOOK_VARIABLES[3], labelKey: 'xpack.alert.templateVariableNodeName' },
{ token: CUSTOM_WEBHOOK_VARIABLES[4], labelKey: 'xpack.alert.templateVariableTimestamp' },
];
const bodyDrafts = reactive<Record<CustomWebhookBodyType, CustomWebhookBody>>({
json: { type: 'json', template: '', fields: [] },
form: { type: 'form', template: '', fields: [] },
text: { type: 'text', template: '{{message}}', fields: [] },
});
watch(
() => props.modelValue.body,
(body) => {
bodyDrafts[body.type] = {
type: body.type,
template: body.template,
fields: body.fields.map((field) => ({ ...field })),
};
},
{ deep: true, immediate: true },
);
const emitValue = (value: CustomWebhookDraft) => emit('update:modelValue', value);
const updateDisplayName = (displayName: string) => emitValue({ ...props.modelValue, displayName });
const changePreset = async (value: string | number | boolean | undefined) => {
const preset = value as CustomWebhookPreset;
if (preset === props.modelValue.preset) return;
const hasBodyContent = Boolean(props.modelValue.body.template || props.modelValue.body.fields.length);
const overwritesBody = preset !== 'custom' && !isCustomWebhookPresetPristine(props.modelValue) && hasBodyContent;
if (overwritesBody) {
try {
await ElMessageBox.confirm(
i18n.global.t('xpack.alert.presetOverwriteHelper'),
i18n.global.t('xpack.alert.webhookPreset'),
{
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
},
);
} catch {
return;
}
}
const next = applyCustomWebhookPreset(props.modelValue, preset);
bodyDrafts[next.body.type] = { ...next.body, fields: next.body.fields.map((field) => ({ ...field })) };
bodyTemplateFocused.value = false;
activeFormFieldIndex.value = null;
emitValue(next);
};
const updateUrlValue = (value: string) => {
emitValue({
...props.modelValue,
url: replaceSecretDraft(props.modelValue.url, value),
});
};
const keepUrl = () => {
emitValue({ ...props.modelValue, url: keepSecretDraft(props.modelValue.url) });
};
const clearUrl = () => {
if (!props.allowClearUrl) return;
emitValue({ ...props.modelValue, url: clearSecretDraft(props.modelValue.url) });
};
const defaultBodyForType = (type: CustomWebhookBodyType): CustomWebhookBody => {
if (type === 'form') return { type, template: '', fields: [createCustomWebhookFormField()] };
if (type === 'text') return { type, template: '{{message}}', fields: [] };
return { type, template: '{}', fields: [] };
};
const changeBodyType = (value: string | number | boolean | undefined) => {
const type = value as CustomWebhookBodyType;
bodyDrafts[props.modelValue.body.type] = {
...props.modelValue.body,
fields: props.modelValue.body.fields.map((field) => ({ ...field })),
};
const cached = bodyDrafts[type];
const body = cached.template || cached.fields.length ? cached : defaultBodyForType(type);
bodyTemplateFocused.value = false;
activeFormFieldIndex.value = null;
emitValue({
...props.modelValue,
preset: 'custom',
body: { ...body, fields: body.fields.map((field) => ({ ...field })) },
});
};
const updateBodyTemplate = (template: string) => {
emitValue(updateCustomWebhookBodyTemplate(props.modelValue, template));
};
const addFormField = () => {
emitValue({
...props.modelValue,
preset: 'custom',
body: { ...props.modelValue.body, fields: [...props.modelValue.body.fields, createCustomWebhookFormField()] },
});
};
const updateFormField = (index: number, field: 'key' | 'value', value: string) => {
const fields = props.modelValue.body.fields.map((item, itemIndex) =>
itemIndex === index ? { ...item, [field]: value } : item,
);
emitValue({ ...props.modelValue, preset: 'custom', body: { ...props.modelValue.body, fields } });
};
const removeFormField = (index: number) => {
emitValue({
...props.modelValue,
preset: 'custom',
body: {
...props.modelValue.body,
fields: props.modelValue.body.fields.filter((_, itemIndex) => itemIndex !== index),
},
});
if (activeFormFieldIndex.value === index) {
activeFormFieldIndex.value = null;
} else if (activeFormFieldIndex.value !== null && activeFormFieldIndex.value > index) {
activeFormFieldIndex.value -= 1;
}
};
const addHeader = () =>
emitValue({ ...props.modelValue, headers: [...props.modelValue.headers, createCustomWebhookHeader()] });
const updateHeader = (index: number, patch: Partial<CustomWebhookDraft['headers'][number]>) => {
const headers = props.modelValue.headers.map((header, itemIndex) =>
itemIndex === index ? { ...header, ...patch } : header,
);
emitValue({ ...props.modelValue, headers });
};
const updateSecretHeaderValue = (index: number, value: string) => {
updateHeader(index, replaceSecretDraft(props.modelValue.headers[index], value));
};
const updateHeaderKey = (index: number, key: string) => {
const current = props.modelValue.headers[index];
if (!isCustomWebhookSecretHeader(key) || current.secret) {
updateHeader(index, { key });
return;
}
updateHeader(index, {
key,
secret: true,
configured: false,
masked: '',
action: 'replace',
});
};
const removeHeader = (index: number) => {
emitValue({ ...props.modelValue, headers: props.modelValue.headers.filter((_, itemIndex) => itemIndex !== index) });
};
const toggleHeaderSecret = (index: number, secret: boolean) => {
const current = props.modelValue.headers[index];
if (!secret && isCustomWebhookSecretHeader(current.key)) return;
updateHeader(index, {
secret,
configured: false,
masked: '',
action: 'replace',
value: current.action === 'replace' ? current.value : '',
});
};
const setHeaderSecretAction = (index: number, action: CustomWebhookSecretAction) => {
const header = props.modelValue.headers[index];
if (action === 'keep') {
updateHeader(index, keepSecretDraft(header));
return;
}
if (action === 'clear') {
updateHeader(index, clearSecretDraft(header));
return;
}
updateHeader(index, replaceSecretDraft(header, ''));
};
const insertVariable = (variable: string) => {
if (props.modelValue.body.type === 'form') {
if (props.modelValue.body.fields.length === 0) {
const field = createCustomWebhookFormField();
field.value = variable;
emitValue({
...props.modelValue,
preset: 'custom',
body: { ...props.modelValue.body, fields: [field] },
});
return;
}
const fallbackIndex = props.modelValue.body.fields.length - 1;
const targetIndex = activeFormFieldIndex.value ?? fallbackIndex;
updateFormField(targetIndex, 'value', props.modelValue.body.fields[targetIndex].value + variable);
return;
}
const textarea = bodyTemplateInputRef.value?.textarea;
const selection =
bodyTemplateFocused.value && textarea
? { start: textarea.selectionStart, end: textarea.selectionEnd }
: undefined;
const insertion = insertCustomWebhookVariable(
props.modelValue.body.template,
variable,
props.modelValue.body.type,
selection,
);
updateBodyTemplate(insertion.template);
void nextTick(() => {
bodyTemplateInputRef.value?.focus();
bodyTemplateInputRef.value?.textarea?.setSelectionRange(insertion.cursor, insertion.cursor);
bodyTemplateFocused.value = true;
});
};
const errorFor = (field: string): string => {
const issue = props.validationIssues.find((item) => item.field === field || item.field.startsWith(`${field}.`));
return issue ? i18n.global.t(`xpack.alert.customWebhookValidation.${issue.code}`) : '';
};
</script>
<style scoped lang="scss">
.custom-webhook-form,
.secret-editor,
.key-value-list,
.header-list {
width: 100%;
}
.secret-editor {
display: flex;
flex-direction: column;
gap: 8px;
}
.secret-editor__actions,
.header-card__footer,
.header-card__secret-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.key-value-list,
.header-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.key-value-row {
display: grid;
grid-template-columns: minmax(120px, 0.8fr) minmax(180px, 1.4fr) auto;
gap: 8px;
align-items: start;
}
.header-card {
display: grid;
grid-template-columns: minmax(150px, 0.8fr) minmax(220px, 1.4fr);
gap: 8px;
padding: 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.header-card__footer {
grid-column: 1 / -1;
justify-content: space-between;
}
.advanced-collapse {
margin-top: 4px;
border-top: 0;
}
.advanced-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
padding-right: 12px;
}
.advanced-title__summary {
color: var(--el-text-color-secondary);
font-size: 12px;
font-weight: 400;
}
.variable-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.variable-tag {
cursor: pointer;
}
@media (max-width: 640px) {
.key-value-row,
.header-card {
grid-template-columns: 1fr;
}
.header-card__footer {
grid-column: 1;
align-items: flex-start;
flex-direction: column;
}
.body-type-group {
display: flex;
width: 100%;
}
.body-type-group :deep(.el-radio-button) {
flex: 1;
}
.body-type-group :deep(.el-radio-button__inner) {
width: 100%;
min-height: 44px;
}
.secret-editor__actions > .el-button,
.key-value-row > .el-button {
min-height: 44px;
}
.advanced-title {
align-items: flex-start;
flex-direction: column;
gap: 2px;
}
}
</style>
@@ -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<CustomWebhookBodyView>;
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<Exclude<CustomWebhookPreset, 'custom'>, 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<CustomWebhookConfigView> = {}): 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<CustomWebhookBodyType, 'form'>,
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<string>();
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<string>();
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<CustomWebhookConfigView>,
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<CustomWebhookConfigView> | 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}`;
};
@@ -1,5 +1,5 @@
<template>
<DrawerPro v-model="drawerVisible" :header="drawerHeader" @close="handleClose" size="736">
<DrawerPro v-model="drawerVisible" :header="drawerHeader" @close="handleClose" :size="isMobile ? 'full' : '736px'">
<el-form
ref="formRef"
:rules="currentRules"
@@ -9,7 +9,7 @@
v-loading="loading"
>
<el-row type="flex" justify="center">
<el-col :span="22">
<el-col :span="isMobile ? 24 : 22">
<el-form-item v-if="!isEdit" :label="$t('commons.table.type')" prop="type">
<el-select v-model="form.type" class="w-full" @change="onTypeChange">
<el-option
@@ -34,8 +34,8 @@
<el-input v-model.trim="form.config.userName" />
<span class="input-help">{{ $t('xpack.alert.userNameHelper') }}</span>
</el-form-item>
<el-form-item :label="$t('xpack.alert.password')" prop="config.password">
<el-input v-model.trim="form.config.password" type="password" show-password />
<el-form-item :label="$t('xpack.alert.password')" prop="emailPassword">
<el-input v-model="form.emailPassword" type="password" show-password />
<span class="input-help">{{ $t('xpack.alert.passwordHelper') }}</span>
</el-form-item>
<el-form-item :label="$t('xpack.alert.host')" prop="config.host">
@@ -90,6 +90,13 @@
</el-form-item>
</template>
<CustomWebhookForm
v-else-if="form.type === 'custom'"
v-model="form.customWebhook"
:validation-issues="customWebhookValidationIssues"
:allow-clear-url="form.status === 'Disable'"
/>
<template v-else>
<el-form-item :label="$t('xpack.alert.webhookName')" prop="webhookName">
<el-input v-model="form.webhookName" />
@@ -107,7 +114,7 @@
</el-form>
<template #footer>
<div v-if="form.type === 'email'" class="flex items-center justify-between">
<el-button v-permission @click="onTest(formRef)" plain type="primary">
<el-button v-permission :disabled="loading" @click="onTest(formRef)" plain type="primary">
{{ $t('xpack.alert.test') }}
</el-button>
<div>
@@ -122,6 +129,22 @@
</el-button>
</div>
</div>
<div v-else-if="form.type === 'custom'" class="custom-webhook-footer">
<el-button v-permission plain type="primary" :loading="testLoading" @click="onTest(formRef)">
{{ $t('xpack.alert.test') }}
</el-button>
<div class="custom-webhook-footer__actions">
<el-button @click="drawerVisible = false">{{ $t('commons.button.cancel') }}</el-button>
<el-button
v-permission
:disabled="loading || testLoading || !customWebhookSaveAllowed"
type="primary"
@click="onSave(formRef)"
>
{{ $t('commons.button.confirm') }}
</el-button>
</div>
</div>
<div v-else class="flex justify-end gap-2">
<el-button @click="drawerVisible = false">{{ $t('commons.button.cancel') }}</el-button>
<el-button v-permission :disabled="loading" type="primary" @click="onSave(formRef)">
@@ -135,16 +158,25 @@
<script lang="ts" setup>
import { computed, onMounted, reactive, ref, watch } from 'vue';
import i18n from '@/lang';
import { MsgError, MsgSuccess } from '@/utils/message';
import { MsgError, MsgSuccess, MsgWarning } from '@/utils/message';
import { FormInstance } from 'element-plus';
import { ListAlertConfigs, TestAlertConfig, UpdateAlertConfig } from '@/api/modules/alert';
import { ListAlertConfigs, TestAlertConfig, TestCustomAlertConfig, UpdateAlertConfig } from '@/api/modules/alert';
import { Rules, checkNumberRange } from '@/global/form-rules';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { Alert } from '@/api/interface/alert';
import CustomWebhookForm from './custom-webhook-form.vue';
import {
CustomWebhookValidationIssue,
createDefaultCustomWebhookDraft,
hydrateCustomWebhookDraft,
serializeCustomWebhookDraft,
validateCustomWebhookDraft,
} from './custom-webhook';
import { buildLegacyEmailTestFields, rawSecretValue, serializeLegacySecretValue } from './secret-field';
const emit = defineEmits<{ (e: 'search'): void }>();
const { isProductPro, isIntl, isEE } = useGlobalStore();
const { isProductPro, isIntl, isEE, isMobile } = useGlobalStore();
const emailRules = {
'config.displayName': [Rules.requiredInput, { validator: checkDisplayNameDuplicate, trigger: 'blur' }],
@@ -156,7 +188,7 @@ const emailRules = {
const smsRules = {
smsDisplayName: [Rules.requiredInput, { validator: checkSmsDisplayNameDuplicate, trigger: 'blur' }],
smsPhone: [Rules.phone, { validator: checkPhoneDuplicate, trigger: 'blur' }],
smsPhone: [Rules.phone],
smsDailyAlertNum: [Rules.integerNumber, checkNumberRange(20, 100)],
};
@@ -165,9 +197,14 @@ const webhookRules = {
webhookUrl: [Rules.requiredInput],
};
const customWebhookRules = {
'customWebhook.displayName': [{ validator: checkDisplayNameDuplicate, trigger: 'blur' }],
};
const currentRules = computed(() => {
if (form.type === 'email') return emailRules;
if (form.type === 'sms') return smsRules;
if (form.type === 'custom') return customWebhookRules;
return webhookRules;
});
@@ -179,6 +216,7 @@ const typeOptions = computed(() => {
options.push({ value: 'feiShu', label: i18n.global.t('xpack.alert.feiShu') });
}
options.push({ value: 'bark', label: i18n.global.t('xpack.alert.bark') });
options.push({ value: 'custom', label: i18n.global.t('xpack.alert.custom') });
if (isProductPro.value && !isEE.value && !isIntl.value) {
options.push({ value: 'sms', label: i18n.global.t('xpack.alert.sms') });
}
@@ -189,7 +227,6 @@ const defaultEmailForm = {
displayName: '',
sender: '',
userName: '',
password: '',
host: '',
port: 465,
encryption: 'NONE',
@@ -199,8 +236,10 @@ const defaultEmailForm = {
const drawerVisible = ref(false);
const loading = ref(false);
const testLoading = ref(false);
const isEdit = ref(false);
const isOK = ref(false);
const emailRevision = ref(0);
const formRef = ref<FormInstance>();
const alertConfigs = ref<Alert.AlertConfigInfo[]>([]);
@@ -218,19 +257,33 @@ const loadAlertConfigs = async () => {
const form = reactive({
id: undefined as number | undefined,
revision: undefined as string | undefined,
type: 'email',
title: '',
status: 'Enable',
updateUser: '',
config: { ...defaultEmailForm } as Record<string, any>,
emailPassword: '',
recipient: '',
webhookName: '',
webhookUrl: '',
smsDisplayName: '',
smsPhone: '',
smsDailyAlertNum: 50,
customWebhook: createDefaultCustomWebhookDraft(),
});
const customWebhookValidationIssues = ref<CustomWebhookValidationIssue[]>([]);
const customWebhookRevision = ref(0);
const testedCustomWebhookRevision = ref<number | null>(null);
const customWebhookTestPassed = computed(
() =>
testedCustomWebhookRevision.value !== null && testedCustomWebhookRevision.value === customWebhookRevision.value,
);
const customWebhookSaveAllowed = computed(
() => customWebhookTestPassed.value || (form.status === 'Disable' && form.customWebhook.url.action === 'clear'),
);
const drawerHeader = computed(() => {
if (isEdit.value) {
return i18n.global.t('xpack.alert.' + form.type);
@@ -300,32 +353,6 @@ function checkSmsDisplayNameDuplicate(_rule: unknown, value: string, callback: (
callback();
}
function checkPhoneDuplicate(_rule: unknown, value: string, callback: (error?: Error) => void) {
const currentValue = normalizeDisplayName(value);
const duplicated = alertConfigs.value.some((item) => {
if (item.type !== 'sms') {
return false;
}
if (form.id && item.id === form.id) {
return false;
}
try {
const config = JSON.parse(item.config || '{}') as { phone?: string };
return normalizeDisplayName(config.phone) === currentValue;
} catch {
return false;
}
});
if (duplicated) {
callback(new Error(i18n.global.t('commons.rule.duplicate')));
return;
}
callback();
}
const titleMap: Record<string, string> = {
email: 'xpack.alert.emailConfig',
weCom: 'xpack.alert.weCom',
@@ -333,10 +360,12 @@ const titleMap: Record<string, string> = {
feiShu: 'xpack.alert.feiShu',
bark: 'xpack.alert.bark',
sms: 'xpack.alert.smsConfig',
custom: 'xpack.alert.custom',
};
interface DrawerProps {
id?: number;
revision?: string;
type?: string;
config?: Record<string, any>;
status?: string;
@@ -344,9 +373,14 @@ interface DrawerProps {
}
const acceptParams = (params: DrawerProps): void => {
form.emailPassword = '';
form.smsPhone = '';
form.webhookUrl = '';
form.customWebhook = createDefaultCustomWebhookDraft();
if (params.id && params.id > 0) {
isEdit.value = true;
form.id = params.id;
form.revision = params.revision;
form.type = params.type || 'email';
form.title = titleMap[form.type] || '';
form.status = params.status || 'Enable';
@@ -354,62 +388,87 @@ const acceptParams = (params: DrawerProps): void => {
if (form.type === 'email') {
form.config = { ...defaultEmailForm, ...(params.config || {}) };
delete form.config.password;
form.emailPassword = rawSecretValue(params.config?.password);
form.recipient = params.config?.recipient || '';
} else if (form.type === 'sms') {
form.smsDisplayName = params.config?.displayName || '';
form.smsPhone = params.config?.phone || '';
form.smsPhone = rawSecretValue(params.config?.phone);
form.smsDailyAlertNum = params.config?.alertDailyNum || 50;
} else if (form.type === 'custom') {
form.customWebhook = hydrateCustomWebhookDraft(params.config || {});
} else {
form.webhookName = params.config?.displayName || '';
form.webhookUrl = params.config?.url || '';
form.webhookUrl = rawSecretValue(params.config?.url);
}
} else {
isEdit.value = false;
form.id = undefined;
form.revision = undefined;
form.type = params.type || 'email';
form.title = titleMap[form.type] || '';
form.status = 'Enable';
form.updateUser = '';
form.config = { ...defaultEmailForm };
form.emailPassword = '';
form.recipient = '';
form.smsDisplayName = '';
form.webhookName = '';
form.webhookUrl = '';
form.smsPhone = '';
form.smsDailyAlertNum = 50;
form.customWebhook = createDefaultCustomWebhookDraft();
}
isOK.value = false;
customWebhookValidationIssues.value = [];
testedCustomWebhookRevision.value = null;
drawerVisible.value = true;
void loadAlertConfigs();
};
const onTypeChange = (type: string) => {
form.emailPassword = '';
form.smsPhone = '';
form.webhookUrl = '';
form.customWebhook = createDefaultCustomWebhookDraft();
form.title = titleMap[type] || '';
if (type === 'email') {
form.config = { ...defaultEmailForm };
form.emailPassword = '';
form.recipient = '';
} else if (type === 'sms') {
form.smsDisplayName = '';
form.smsPhone = '';
form.smsDailyAlertNum = 50;
} else if (type === 'custom') {
form.config = {};
form.customWebhook = createDefaultCustomWebhookDraft();
} else {
form.config = {};
form.webhookName = '';
form.webhookUrl = '';
}
isOK.value = false;
customWebhookValidationIssues.value = [];
testedCustomWebhookRevision.value = null;
formRef.value?.clearValidate();
};
const buildEmailConfig = () => ({
...form.config,
password: serializeLegacySecretValue(form.emailPassword),
recipient: form.recipient,
});
const revisionPayload = () => (form.id && form.revision ? { revision: form.revision } : {});
const buildSavePayload = () => {
if (form.type === 'email') {
const configInfo = {
...form.config,
recipient: form.recipient,
};
const configInfo = buildEmailConfig();
return {
id: form.id,
id: form.id || 0,
...revisionPayload(),
type: 'email',
title: titleMap['email'],
status: form.status,
@@ -420,11 +479,12 @@ const buildSavePayload = () => {
if (form.type === 'sms') {
const configInfo = {
displayName: form.smsDisplayName,
phone: form.smsPhone,
phone: serializeLegacySecretValue(form.smsPhone, true),
alertDailyNum: form.smsDailyAlertNum,
};
return {
id: form.id,
id: form.id || 0,
...revisionPayload(),
type: 'sms',
title: titleMap['sms'],
status: form.status,
@@ -432,12 +492,25 @@ const buildSavePayload = () => {
displayName: configInfo.displayName,
};
}
if (form.type === 'custom') {
const configInfo = serializeCustomWebhookDraft(form.customWebhook);
return {
id: form.id || 0,
...revisionPayload(),
type: 'custom',
title: titleMap['custom'],
status: form.status,
config: JSON.stringify(configInfo),
displayName: configInfo.displayName,
};
}
const configInfo = {
displayName: form.webhookName,
url: form.webhookUrl,
url: serializeLegacySecretValue(form.webhookUrl, true),
};
return {
id: form.id,
id: form.id || 0,
...revisionPayload(),
type: form.type,
title: form.title,
status: form.status,
@@ -446,37 +519,119 @@ const buildSavePayload = () => {
};
};
const validateCustomWebhook = async (formEl: FormInstance, allowClearedUrl = false): Promise<boolean> => {
customWebhookValidationIssues.value = validateCustomWebhookDraft(form.customWebhook, { allowClearedUrl });
if (customWebhookValidationIssues.value.length > 0) {
const issue = customWebhookValidationIssues.value[0];
MsgError(i18n.global.t(`xpack.alert.customWebhookValidation.${issue.code}`));
return false;
}
try {
await formEl.validate();
return true;
} catch {
return false;
}
};
const saveAlertConfig = async () => {
loading.value = true;
try {
await UpdateAlertConfig(buildSavePayload());
void loadAlertConfigs();
handleClose();
emit('search');
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
} finally {
loading.value = false;
}
};
const onSave = async (formEl: FormInstance | undefined) => {
if (!formEl) return;
if (isEdit.value && !form.revision) {
MsgError(i18n.global.t('xpack.alert.alertConfigChanged'));
return;
}
if (form.type === 'custom') {
if (!customWebhookSaveAllowed.value) return;
if (await validateCustomWebhook(formEl, form.status === 'Disable')) {
try {
await saveAlertConfig();
} catch {
return;
}
}
return;
}
await formEl.validate(async (valid) => {
if (!valid) return;
loading.value = true;
try {
await UpdateAlertConfig(buildSavePayload());
loading.value = false;
void loadAlertConfigs();
handleClose();
emit('search');
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
} catch (error) {
loading.value = false;
await saveAlertConfig();
} catch {
return;
}
});
};
const onTest = async (formEl: FormInstance | undefined) => {
if (!formEl || form.type !== 'email') return;
if (!formEl) return;
if (form.type === 'custom') {
testedCustomWebhookRevision.value = null;
if (!(await validateCustomWebhook(formEl))) return;
testLoading.value = true;
try {
const testedRevision = customWebhookRevision.value;
const config = JSON.stringify(serializeCustomWebhookDraft(form.customWebhook));
const res = await TestCustomAlertConfig({
...(form.id ? { id: form.id } : {}),
type: 'custom',
config,
});
const raw = res.data as Alert.AlertConfigCustomTestResult | boolean;
const result = typeof raw === 'boolean' ? undefined : raw;
const success = typeof raw === 'boolean' ? raw : Boolean(result?.success);
const message =
result?.message || i18n.global.t(success ? 'xpack.alert.alertTestOk' : 'xpack.alert.alertTestFailed');
if (success) {
if (testedRevision !== customWebhookRevision.value) {
MsgWarning(i18n.global.t('xpack.alert.testResultStale'));
return;
}
testedCustomWebhookRevision.value = testedRevision;
MsgSuccess(message);
return;
}
MsgError(message);
} catch {
return;
} finally {
testLoading.value = false;
}
return;
}
if (form.type !== 'email') return;
await formEl.validate(async (valid) => {
if (!valid) return;
loading.value = true;
isOK.value = false;
const testedEmailRevision = emailRevision.value;
try {
const testConfig = {
...form.config,
recipient: form.recipient,
} as Alert.AlertConfigTest;
const emailConfig = buildEmailConfig();
const legacyEmailFields = buildLegacyEmailTestFields(emailConfig);
const testConfig: Alert.AlertConfigTest = {
...legacyEmailFields,
...(form.id ? { id: form.id } : {}),
type: 'email',
config: JSON.stringify(emailConfig),
};
const res = await TestAlertConfig(testConfig);
loading.value = false;
if (res.data) {
if (testedEmailRevision !== emailRevision.value) {
MsgWarning(i18n.global.t('xpack.alert.testResultStale'));
return;
}
isOK.value = true;
MsgSuccess(i18n.global.t('xpack.alert.alertTestOk'));
} else {
@@ -484,7 +639,7 @@ const onTest = async (formEl: FormInstance | undefined) => {
}
} catch {
loading.value = false;
MsgError(i18n.global.t('xpack.alert.alertTestFailed'));
return;
}
});
};
@@ -494,9 +649,31 @@ watch(
() => {
if (form.type === 'email') {
isOK.value = false;
emailRevision.value += 1;
}
},
{ deep: true },
{ deep: true, flush: 'sync' },
);
watch(
() => form.emailPassword,
() => {
if (form.type !== 'email') return;
isOK.value = false;
emailRevision.value += 1;
},
{ deep: true, flush: 'sync' },
);
watch(
() => form.customWebhook,
() => {
if (form.type !== 'custom') return;
customWebhookValidationIssues.value = [];
customWebhookRevision.value += 1;
testedCustomWebhookRevision.value = null;
},
{ deep: true, flush: 'sync' },
);
watch(
@@ -504,8 +681,10 @@ watch(
() => {
if (form.type === 'email') {
isOK.value = false;
emailRevision.value += 1;
}
},
{ flush: 'sync' },
);
watch(
@@ -515,10 +694,12 @@ watch(
formRef.value?.clearValidate(['smsDisplayName', 'smsPhone']);
}
},
{ deep: true, flush: 'sync' },
);
const handleClose = () => {
isOK.value = false;
testedCustomWebhookRevision.value = null;
drawerVisible.value = false;
};
@@ -530,3 +711,34 @@ defineExpose({
acceptParams,
});
</script>
<style scoped lang="scss">
.custom-webhook-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.custom-webhook-footer__actions {
display: flex;
gap: 8px;
}
@media (max-width: 640px) {
.custom-webhook-footer {
align-items: stretch;
flex-direction: column;
}
.custom-webhook-footer > .el-button,
.custom-webhook-footer__actions > .el-button {
min-height: 44px;
}
.custom-webhook-footer__actions {
display: grid;
grid-template-columns: 1fr 1fr;
}
}
</style>
@@ -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 = <T extends SecretDraft>(draft: T, value: string): T => ({
...draft,
action: 'replace',
value,
});
export const keepSecretDraft = <T extends SecretDraft>(draft: T): T => ({
...draft,
action: 'keep',
value: draft.originalValue || '',
});
export const clearSecretDraft = <T extends SecretDraft>(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<string, unknown>).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<string, unknown>): 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, unknown>): 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 '';
};
@@ -49,7 +49,7 @@
<template #title>
<div class="flex items-center justify-start">
{{ $t('xpack.alert.alertConfigHelper') }}
<span v-if="!isProductPro">
<span v-if="!isProductPro && !isEE">
{{ $t('commons.units.semicolon') }}{{ $t('xpack.alert.alertConfigProHelper') }}
</span>
<el-link
@@ -89,15 +89,24 @@
</el-table-column>
<el-table-column :label="$t('xpack.alert.configDetail')" min-width="240" prop="details">
<template #default="{ row }">
<div class="text-sm cursor-pointer select-none" @click="toggleDetail(row.id!)">
<template v-if="expandedIds.has(row.id!)">
{{ getConfigDetails(row) }}
<el-icon class="ml-1 align-middle text-gray-400"><View /></el-icon>
</template>
<template v-else>
{{ getConfigSummary(row) }}
<el-icon class="ml-1 align-middle text-gray-400"><Hide /></el-icon>
</template>
<div class="config-detail text-sm">
<span class="config-detail__value">
{{ expandedIds.has(row.id!) ? getConfigDetails(row) : getConfigSummary(row) }}
</span>
<el-button
link
class="config-detail__toggle"
:title="
$t(expandedIds.has(row.id!) ? 'commons.button.hide' : 'commons.button.view')
"
:aria-label="
$t(expandedIds.has(row.id!) ? 'commons.button.hide' : 'commons.button.view')
"
@click="toggleDetail(row.id!)"
>
<el-icon v-if="expandedIds.has(row.id!)"><View /></el-icon>
<el-icon v-else><Hide /></el-icon>
</el-button>
</div>
</template>
</el-table-column>
@@ -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)"
/>
</template>
@@ -142,14 +151,22 @@
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { ListAlertConfigs, PageAlertConfigs, DeleteAlertConfig, UpdateAlertConfig } from '@/api/modules/alert';
import {
ListAlertConfigs,
PageAlertConfigs,
DeleteAlertConfig,
UpdateAlertConfig,
UpdateAlertConfigStatus,
} from '@/api/modules/alert';
import { ElMessageBox } from 'element-plus';
import { Message, ChatDotRound, Bell, View, Hide } from '@element-plus/icons-vue';
import { Message, ChatDotRound, Bell, View, Hide, Link } from '@element-plus/icons-vue';
import SendTimeRange from '@/views/setting/alert/setting/time-range/index.vue';
import i18n from '@/lang';
import { MsgSuccess } from '@/utils/message';
import { MsgError, MsgSuccess } from '@/utils/message';
import AlertDrawer from '@/views/setting/alert/setting/drawer/index.vue';
import { Alert } from '@/api/interface/alert';
import { formatCustomWebhookDetails, formatCustomWebhookSafeSummary } from './drawer/custom-webhook';
import { getAlertConfigDisplayName, rawSecretValue } from './drawer/secret-field';
const { docsUrl, isMaster, isMobile, isProductPro, isEE, isIntl } = useGlobalStore();
@@ -162,7 +179,6 @@ const alertFormRef = ref();
const allConfigs = ref<Alert.AlertConfigInfo[]>([]);
const expandedIds = ref<Set<number>>(new Set());
const toggleDetail = (id: number) => {
const next = new Set(expandedIds.value);
if (next.has(id)) {
@@ -220,14 +236,8 @@ const parseConfig = <T extends object>(raw: Alert.AlertConfigInfo, fallback: T):
function getDisplayName(row: Alert.AlertConfigInfo): string {
try {
const cfg = JSON.parse(row.config || '{}');
if (row.type === 'email') {
return cfg.displayName || '';
}
if (cfg.webhooks && cfg.webhooks.length > 0) {
return cfg.webhooks.map((w: { displayName: string }) => w.displayName).join(', ');
}
return cfg.displayName || '';
const cfg = JSON.parse(row.config || '{}') as Record<string, unknown>;
return getAlertConfigDisplayName(row.type, cfg);
} catch {
return '';
}
@@ -242,18 +252,24 @@ function getConfigDetails(row: Alert.AlertConfigInfo): string {
return `${cfg.sender || ''}${cfg.host || ''}:${cfg.port || ''} | ${i18n.global.t('xpack.alert.recipient')}: ${recipients}`;
}
if (row.type === 'sms') {
return `${i18n.global.t('xpack.alert.phone')}: ${cfg.phone || ''}`;
return `${i18n.global.t('xpack.alert.phone')}: ${rawSecretValue(cfg.phone)}`;
}
if (row.type === 'custom') {
return formatCustomWebhookDetails(cfg, i18n.global.t('commons.msg.noneData'));
}
if (cfg.webhooks && cfg.webhooks.length > 0) {
return cfg.webhooks
.map((w: { displayName: string; url: string }) => `${w.displayName}: ${w.url}`)
.map(
(webhook: { displayName: string; url: unknown }) =>
`${webhook.displayName}: ${rawSecretValue(webhook.url)}`,
)
.join(' | ');
}
if (cfg.displayName && cfg.url) {
return `${cfg.displayName}: ${cfg.url}`;
return `${cfg.displayName}: ${rawSecretValue(cfg.url)}`;
}
if (cfg.url) {
return cfg.url;
return rawSecretValue(cfg.url);
}
return '';
} catch {
@@ -275,15 +291,12 @@ function getConfigSummary(row: Alert.AlertConfigInfo): string {
return `${maskString(cfg.sender || '')}${cfg.host || ''}:${cfg.port || ''} | ${i18n.global.t('xpack.alert.recipient')}: ${recipientCount}`;
}
if (row.type === 'sms') {
return `${i18n.global.t('xpack.alert.phone')}: ${maskString(cfg.phone || '', 4)}`;
return `${i18n.global.t('xpack.alert.phone')}: ${maskString(rawSecretValue(cfg.phone), 4)}`;
}
if (cfg.webhooks && cfg.webhooks.length > 0) {
return cfg.webhooks.map((w: { displayName: string }) => w.displayName).join(', ');
if (row.type === 'custom') {
return formatCustomWebhookSafeSummary(cfg, { includeUrl: false });
}
if (cfg.displayName) {
return cfg.displayName;
}
return '***';
return getAlertConfigDisplayName(row.type, cfg) || '***';
} catch {
return '***';
}
@@ -297,6 +310,7 @@ const getTypeIcon = (type: string) => {
dingTalk: ChatDotRound,
feiShu: ChatDotRound,
bark: Bell,
custom: Link,
};
return map[type] || Message;
};
@@ -309,6 +323,7 @@ const getTypeColor = (type: string) => {
dingTalk: '#409eff',
feiShu: '#7c3aed',
bark: '#e6a23c',
custom: '#6366f1',
};
return map[type] || '#909399';
};
@@ -321,11 +336,13 @@ const getTypeTagType = (type: string) => {
dingTalk: '',
feiShu: 'danger',
bark: 'warning',
custom: 'primary',
};
return map[type] || 'info';
};
const searchConfigs = async () => {
expandedIds.value = new Set();
loading.value = true;
try {
const [configRes, pageRes] = await Promise.all([
@@ -335,7 +352,6 @@ const searchConfigs = async () => {
pageSize: paginationConfig.pageSize,
}),
]);
const commonFound = configRes.data?.find((s: Alert.AlertConfigInfo) => s.type === 'common');
if (commonFound) {
const parsedConfig = parseConfig(commonFound, defaultCommonConfig.config);
@@ -429,21 +445,21 @@ const onChangeOffline = async () => {
}
};
const onStatusChange = (row: Alert.AlertConfigInfo) => {
UpdateAlertConfig({
id: row.id!,
type: row.type,
title: row.title,
status: row.status,
config: row.config,
displayName: i18n.global.t(row.title),
})
.then(() => {
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
})
.catch(() => {
row.status = row.status === 'Enable' ? 'Disable' : 'Enable';
const onStatusChange = async (row: Alert.AlertConfigInfo) => {
try {
await UpdateAlertConfigStatus({
id: row.id!,
status: row.status,
});
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
await searchConfigs();
} catch {
row.status = row.status === 'Enable' ? 'Disable' : 'Enable';
}
};
const isStatusChangeDisabled = (row: Alert.AlertConfigInfo): boolean => {
return !isProductPro.value && ['weCom', 'dingTalk', 'feiShu', 'sms'].includes(row.type);
};
const onDelete = (id: number) => {
@@ -462,12 +478,17 @@ const onCreate = () => {
};
const openEditDrawer = (row: Alert.AlertConfigInfo) => {
if (!row.updatedAt) {
MsgError(i18n.global.t('xpack.alert.alertConfigChanged'));
return;
}
let configData: Record<string, any> = {};
try {
configData = JSON.parse(row.config || '{}');
} catch {}
alertDrawerRef.value.acceptParams({
id: row.id,
revision: row.updatedAt,
type: row.type,
config: configData,
status: row.status,
@@ -483,7 +504,8 @@ const buttons = computed(() => [
openEditDrawer(row);
},
disabled: (row: Alert.AlertConfigInfo) =>
(isIntl.value || !isProductPro.value) && ['weCom', 'dingTalk', 'feiShu', 'sms'].includes(row.type),
!row.updatedAt ||
((isIntl.value || !isProductPro.value) && ['weCom', 'dingTalk', 'feiShu', 'sms'].includes(row.type)),
},
{
label: i18n.global.t('commons.button.delete'),
@@ -503,4 +525,21 @@ onMounted(async () => {
.label {
color: var(--el-text-color-placeholder);
}
.config-detail {
align-items: flex-start;
display: flex;
gap: 4px;
}
.config-detail__value {
min-width: 0;
overflow-wrap: anywhere;
white-space: normal;
}
.config-detail__toggle {
flex: none;
padding: 2px;
}
</style>
@@ -243,6 +243,7 @@ import { Alert } from '@/api/interface/alert';
import { ListAlertConfigs } from '@/api/modules/alert';
import { specOptions, transObjToSpec, transSpecToObj, weekOptions } from '@/views/cronjob/cronjob/helper';
import { splitTimeFromSecond, transferTimeToSecond } from '@/utils/validate';
import { getAlertConfigDisplayName } from '@/views/setting/alert/setting/drawer/secret-field';
const { isProductPro } = useGlobalStore();
const alertConfigs = ref<Alert.AlertConfigInfo[]>([]);
@@ -274,6 +275,8 @@ const legacyAlertMethodTypeMap: Record<string, string> = {
weCom: 'weCom',
dingTalk: 'dingTalk',
feiShu: 'feiShu',
webhook: 'custom',
custom: 'custom',
};
const normalizeAlertMethodItems = (methods: string[]) => {
@@ -295,7 +298,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);
@@ -308,6 +311,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;
});
@@ -317,8 +328,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<string, unknown>;
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}`);
}