mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-09-22 08:00:53 +00:00
feat: add support for custom webhook configuration (#13685)
This commit is contained in:
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -5787,4 +5787,4 @@
|
||||
"formatZH": "从主节点同步设置",
|
||||
"formatEN": "sync settings from master"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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>", "",
|
||||
" ", " ",
|
||||
)
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:"-"`
|
||||
}
|
||||
|
||||
@@ -5787,4 +5787,4 @@
|
||||
"formatZH": "从主节点同步设置",
|
||||
"formatEN": "sync settings from master"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -199,6 +199,6 @@ defineExpose({
|
||||
border: 1px solid var(--el-color-warning);
|
||||
background-color: transparent;
|
||||
padding: 8px 8px;
|
||||
width: 70px;
|
||||
width: 71px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'وبهوک',
|
||||
|
||||
@@ -937,7 +937,6 @@ const message = {
|
||||
from_remote: 'このモデルは1Panelを介してダウンロードされておらず、関連するプルログはありません。',
|
||||
no_logs: 'このモデルのプルログは削除されており、関連するログを表示できません。',
|
||||
vllmVersionHelper: 'FusionXpark GB 10 サーバーでは -cu130 バージョンを選択してください。',
|
||||
ascendVisibleDevices: 'Ascend 可視デバイス',
|
||||
vllmCommandPortHelper:
|
||||
'起動コマンドではポート {0} を使用する必要があります。使用しない場合、サービスにアクセスできません。',
|
||||
ascendVisibleDevices: 'Ascend 可視デバイス(ASCEND_RT_VISIBLE_DEVICES)',
|
||||
@@ -1129,7 +1128,6 @@ const message = {
|
||||
cachedToken: 'キャッシュ Token',
|
||||
cacheHitRate: 'キャッシュヒット率',
|
||||
activeUsers: 'アクティブユーザー',
|
||||
activeStreamingRequests: 'アクティブなストリーミングリクエスト',
|
||||
activeModels: 'アクティブモデル',
|
||||
failedRequests: '失敗リクエスト',
|
||||
averageTokenPerRequest: '平均 Token/リクエスト',
|
||||
@@ -6699,7 +6697,57 @@ const message = {
|
||||
barkConfigHelper: 'Barkアラート通知設定',
|
||||
webhookName: 'ボット名',
|
||||
webhookUrl: 'Webhook URL',
|
||||
alertConfigProHelper: '商用版では、WeCom、DingTalk、Feishu、SMS 通知も利用できます。',
|
||||
custom: 'Webhook',
|
||||
webhookPreset: 'プリセット',
|
||||
genericJsonPreset: '汎用 JSON',
|
||||
customPreset: 'カスタム',
|
||||
webhookUrlSecretHelper: 'Webhook URL は暗号化して保存され、ここで表示および編集できます',
|
||||
webhookPublicAddressHelper:
|
||||
'公開ネットワークから到達可能な HTTP/HTTPS アドレスのみ対応し、ローカル、プライベート、予約済みアドレスは拒否されます',
|
||||
customWebhookRecoveryRequired:
|
||||
'この設定は無効か旧バージョンのものです。Webhook URL と必要なシークレット Header を再入力して保存してください。',
|
||||
clearSecret: 'クリア',
|
||||
keepSecret: '変更しない',
|
||||
secretCleared: '保存するとこのシークレットはクリアされます',
|
||||
bodyType: 'Body タイプ',
|
||||
bodyTemplate: 'Body テンプレート',
|
||||
formFieldName: 'フィールド名',
|
||||
formFieldValue: 'フィールド値',
|
||||
addFormField: 'フィールドを追加',
|
||||
webhookAdvanced: '詳細設定',
|
||||
headers: 'Headers',
|
||||
headerName: 'Header 名',
|
||||
headerValue: 'Header 値',
|
||||
secretValue: 'シークレット値',
|
||||
addHeader: 'Header を追加',
|
||||
templateVariables: 'テンプレート変数',
|
||||
templateVariableTitle: 'アラートタイトル',
|
||||
templateVariableMessage: 'アラート内容',
|
||||
templateVariableType: 'アラートタイプ',
|
||||
templateVariableNodeName: 'ノード名',
|
||||
templateVariableTimestamp: '発生時刻',
|
||||
templateVariablesHelper:
|
||||
'title=アラートタイトル、message=アラート内容、type=アラートタイプ、nodeName=ノード名、timestamp=発生時刻。変数をクリックすると Body に挿入できます。',
|
||||
testResultStale: '設定が変更されたため、前回のテスト結果は無効です',
|
||||
alertConfigChanged: '設定が更新されました。更新してからもう一度お試しください',
|
||||
presetOverwriteHelper: 'プリセットを切り替えると現在の Body 設定が上書きされます。続行しますか?',
|
||||
customWebhookValidation: {
|
||||
displayNameRequired: '表示名を入力してください',
|
||||
urlRequired: 'Webhook URL を入力してください',
|
||||
urlInvalid: 'Webhook URL には有効な HTTP または HTTPS URL を指定してください',
|
||||
bodyRequired: 'リクエスト Body を設定してください',
|
||||
jsonInvalid: 'JSON Body テンプレートが無効です',
|
||||
formFieldRequired: 'Form フィールド名は必須です',
|
||||
formFieldDuplicate: 'Form フィールド名は重複できません',
|
||||
headerRequired: 'Header 名は必須です',
|
||||
headerInvalid: 'Header 名が無効です',
|
||||
headerDuplicate: 'Header 名は重複できません',
|
||||
headerReserved: 'この Header はシステム管理のためカスタマイズできません',
|
||||
headerMustBeSecret: '認証情報を含む Header はシークレット値に設定してください',
|
||||
templateVariableInvalid: 'Body に未対応のテンプレート変数が含まれています',
|
||||
secretRequired: 'シークレット値を入力するか、変更しない/クリアを選択してください',
|
||||
},
|
||||
alertConfigProHelper: '商用版では、追加の通知チャネルを利用できます。',
|
||||
},
|
||||
theme: {
|
||||
lingXiaGold: 'LXware Gold',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -954,7 +954,6 @@ const message = {
|
||||
from_remote: 'Este modelo não foi baixado pelo 1Panel; não há logs de download relacionados.',
|
||||
no_logs: 'Os logs de download deste modelo foram excluídos e não podem ser visualizados.',
|
||||
vllmVersionHelper: 'Para servidores FusionXpark GB 10, selecione a versão -cu130.',
|
||||
ascendVisibleDevices: 'Dispositivos Ascend visíveis',
|
||||
vllmCommandPortHelper:
|
||||
'O comando de inicialização deve usar a porta {0}; caso contrário, o serviço ficará inacessível.',
|
||||
ascendVisibleDevices: 'Dispositivos Ascend visíveis (ASCEND_RT_VISIBLE_DEVICES)',
|
||||
@@ -1148,7 +1147,6 @@ const message = {
|
||||
cachedToken: 'Tokens em cache',
|
||||
cacheHitRate: 'Taxa de acerto do cache',
|
||||
activeUsers: 'Usuários ativos',
|
||||
activeStreamingRequests: 'Solicitações de streaming ativas',
|
||||
activeModels: 'Modelos ativos',
|
||||
failedRequests: 'Requisições com falha',
|
||||
averageTokenPerRequest: 'Média de Token/requisição',
|
||||
@@ -6848,8 +6846,57 @@ const message = {
|
||||
barkConfigHelper: 'Configuração de notificação de alerta Bark',
|
||||
webhookName: 'Nome do bot',
|
||||
webhookUrl: 'URL do Webhook',
|
||||
alertConfigProHelper:
|
||||
'A edição comercial também oferece suporte a alertas via WeCom, DingTalk, Feishu e SMS.',
|
||||
custom: 'Webhook',
|
||||
webhookPreset: 'Predefinição',
|
||||
genericJsonPreset: 'JSON genérico',
|
||||
customPreset: 'Personalizado',
|
||||
webhookUrlSecretHelper: 'A URL do Webhook é armazenada criptografada e pode ser vista e editada aqui',
|
||||
webhookPublicAddressHelper:
|
||||
'Somente endereços HTTP/HTTPS acessíveis publicamente são aceitos; endereços locais, privados e reservados são bloqueados',
|
||||
customWebhookRecoveryRequired:
|
||||
'Esta configuração é inválida ou veio de uma versão anterior. Informe novamente a URL do Webhook e os headers secretos necessários e salve.',
|
||||
clearSecret: 'Limpar',
|
||||
keepSecret: 'Manter inalterado',
|
||||
secretCleared: 'Este segredo será limpo ao salvar',
|
||||
bodyType: 'Tipo do Body',
|
||||
bodyTemplate: 'Modelo do Body',
|
||||
formFieldName: 'Nome do campo',
|
||||
formFieldValue: 'Valor do campo',
|
||||
addFormField: 'Adicionar campo',
|
||||
webhookAdvanced: 'Avançado',
|
||||
headers: 'Headers',
|
||||
headerName: 'Nome do Header',
|
||||
headerValue: 'Valor do Header',
|
||||
secretValue: 'Valor secreto',
|
||||
addHeader: 'Adicionar Header',
|
||||
templateVariables: 'Variáveis do modelo',
|
||||
templateVariableTitle: 'Título do alerta',
|
||||
templateVariableMessage: 'Conteúdo do alerta',
|
||||
templateVariableType: 'Tipo de alerta',
|
||||
templateVariableNodeName: 'Nome do nó',
|
||||
templateVariableTimestamp: 'Hora do evento',
|
||||
templateVariablesHelper:
|
||||
'title=título do alerta, message=conteúdo, type=tipo, nodeName=nome do nó, timestamp=hora do evento. Clique para inserir a variável no Body.',
|
||||
testResultStale: 'A configuração foi alterada; o resultado do teste anterior não é mais válido',
|
||||
alertConfigChanged: 'A configuração foi atualizada. Atualize a página e tente novamente.',
|
||||
presetOverwriteHelper: 'Trocar a predefinição substituirá a configuração atual do Body. Continuar?',
|
||||
customWebhookValidation: {
|
||||
displayNameRequired: 'Informe um nome de exibição',
|
||||
urlRequired: 'Informe uma URL de Webhook',
|
||||
urlInvalid: 'A URL do Webhook deve ser uma URL HTTP ou HTTPS válida',
|
||||
bodyRequired: 'Configure o Body da solicitação',
|
||||
jsonInvalid: 'O modelo JSON do Body é inválido',
|
||||
formFieldRequired: 'Os nomes dos campos Form são obrigatórios',
|
||||
formFieldDuplicate: 'Os nomes dos campos Form devem ser únicos',
|
||||
headerRequired: 'Os nomes dos Headers são obrigatórios',
|
||||
headerInvalid: 'O nome do Header é inválido',
|
||||
headerDuplicate: 'Os nomes dos Headers devem ser únicos',
|
||||
headerReserved: 'Este Header é gerenciado pelo sistema e não pode ser personalizado',
|
||||
headerMustBeSecret: 'Headers de autenticação ou credenciais devem ser marcados como valores secretos',
|
||||
templateVariableInvalid: 'O Body contém uma variável de modelo não compatível',
|
||||
secretRequired: 'Informe um segredo ou escolha Manter inalterado ou Limpar',
|
||||
},
|
||||
alertConfigProHelper: 'A edição comercial adiciona mais canais de notificação.',
|
||||
},
|
||||
theme: {
|
||||
lingXiaGold: 'LXware Gold',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user