Compare commits

..
35 changed files with 628 additions and 179 deletions
+20 -3
View File
@@ -10,6 +10,9 @@ import (
"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/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/webhook_sender"
"github.com/gin-gonic/gin"
)
@@ -378,10 +381,24 @@ func (b *BaseApi) TestAlertConfig(c *gin.Context) {
return
}
if req.Type == constant.Custom {
result, err := alertService.TestCustomAlertConfig(req)
lang := strings.TrimSpace(c.GetHeader("Accept-Language"))
if lang == "" {
lang = i18n.GetLanguageFromDB()
}
result, err := alertService.TestCustomAlertConfig(req, lang)
if err != nil {
helper.InternalServer(c, err)
return
if global.LOG != nil {
global.LOG.Warnf("webhook test preparation failed: configID=%d error=%q", req.ID,
webhook_sender.DiagnosticText(err.Error(), webhook_sender.Request{
Format: webhook_sender.BodyJSON, Body: []byte(req.Config),
}))
}
result = dto.AlertConfigTestResult{Success: false}
}
result.Response = ""
result.Message = ""
if !result.Success {
result.Message = i18n.GetMsgByKeyAndLang(lang, "ErrWebhookTestFailed")
}
helper.SuccessWithData(c, result)
return
+7 -4
View File
@@ -77,7 +77,7 @@ type IAlertService interface {
UpdateAlertConfigStatus(req dto.AlertConfigStatusUpdate, operator string) error
DeleteAlertConfig(id uint) error
TestAlertConfig(req dto.AlertConfigTest) (bool, error)
TestCustomAlertConfig(req dto.AlertConfigTest) (dto.AlertConfigTestResult, error)
TestCustomAlertConfig(req dto.AlertConfigTest, lang string) (dto.AlertConfigTestResult, error)
}
func NewIAlertService() IAlertService {
@@ -851,7 +851,7 @@ func (a AlertService) TestAlertConfig(req dto.AlertConfigTest) (bool, error) {
msg := email.EmailMessage{
Subject: i18n.GetMsgByKey("TestAlertTitle"),
Body: i18n.GetMsgByKey("TestAlert"),
Body: i18n.GetMsgByKey("TestAlertMessage"),
IsHTML: false,
}
transport := xpack.MultiNodeProvider.LoadRequestTransport()
@@ -899,7 +899,7 @@ func resolveEmailTestConfig(req dto.AlertConfigTest) (dto.AlertEmailConfig, erro
return emailConfig, nil
}
func (a AlertService) TestCustomAlertConfig(req dto.AlertConfigTest) (dto.AlertConfigTestResult, error) {
func (a AlertService) TestCustomAlertConfig(req dto.AlertConfigTest, lang string) (dto.AlertConfigTestResult, error) {
if req.Type != constant.Custom {
return dto.AlertConfigTestResult{}, fmt.Errorf("alert config test type must be custom")
}
@@ -928,12 +928,15 @@ func (a AlertService) TestCustomAlertConfig(req dto.AlertConfigTest) (dto.AlertC
}
tester, ok := xpack.AlertProvider.(providers.CustomWebhookTester)
if !ok {
if global.LOG != nil {
global.LOG.Warnf("webhook test failed: %v", providers.ErrCustomWebhookUnsupported)
}
return dto.AlertConfigTestResult{
Success: false,
Message: providers.ErrCustomWebhookUnsupported.Error(),
}, nil
}
return tester.TestCustomWebhook(resolved)
return tester.TestCustomWebhook(resolved, lang)
}
func (a AlertService) ExternalUpdateAlert(updateAlert dto.AlertCreate, operator string) error {
+3 -2
View File
@@ -633,14 +633,15 @@ AppAttributesNotMatch: 'Application type or name does not match'
# alert
ErrAlertMethodNotSupported: 'This version does not support this alert method'
ErrAlertSync: 'Failed to sync alert message'
ErrWebhookTestFailed: 'Please check the Webhook configuration. See the system logs for details.'
# task - runtime
ErrInstallExtension: 'Another extension install task is running'
# alert mail template
PanelAlertTitle: "Panel Alert Notification"
TestAlertTitle: "Test Email - Verify Email Connectivity"
TestAlert: "This is a test email to verify that your email sending configuration is correct."
TestAlertTitle: "Panel Test Notification"
TestAlertMessage: "This is a test notification to verify the notification channel configuration and connectivity."
LicenseExpirationAlert: "Panel {{ .node }}{{ .ip }}: license expires in {{ .day }} days. Log in to view details."
CronJobFailedAlert: "Panel {{ .node }}{{ .ip }}: scheduled task {{ .name }} failed. Log in to view details."
ClamAlert: "Panel {{ .node }}{{ .ip }}: virus scan found {{ .num }} infected files. Log in to view details."
+3 -2
View File
@@ -633,14 +633,15 @@ AppAttributesNotMatch: 'El tipo o nombre de la aplicación no coincide'
# alerta
ErrAlertMethodNotSupported: 'Esta versión no admite este método de alerta'
ErrAlertSync: 'Error en la sincronización de la alerta, revise e intente de nuevo'
ErrWebhookTestFailed: 'Revisa la configuración del Webhook. Consulta los registros del sistema para más detalles.'
# tarea - entorno de ejecución
ErrInstallExtension: 'Ya hay una tarea de instalación en curso, espere a que termine'
# plantilla de correo de alerta
PanelAlertTitle: 'Notificación de alerta del panel'
TestAlertTitle: 'Correo de prueba - Verificar conectividad de correo'
TestAlert: 'Este es un correo de prueba para verificar que la configuración de envío de correos es correcta.'
TestAlertTitle: 'Notificación de prueba del panel'
TestAlertMessage: 'Esta es una notificación de prueba para verificar la configuración y la conectividad del canal de notificaciones.'
LicenseExpirationAlert: 'Su Panel {{ .node }}{{ .ip }}, la licencia expirará en {{ .day }} días. Inicie sesión en el panel para ver los detalles.'
CronJobFailedAlert: 'Su Panel {{ .node }}{{ .ip }}, la ejecución de la tarea programada {{ .name }} falló. Inicie sesión en el panel para ver los detalles.'
ClamAlert: 'Su Panel {{ .node }}{{ .ip }}, la tarea de escaneo antivirus detectó {{ .num }} archivos infectados. Inicie sesión en el panel para ver los detalles.'
+3 -2
View File
@@ -633,14 +633,15 @@ AppAttributesNotMatch: 'نوع یا نام برنامه مطابقت ندارد'
# هشدار
ErrAlertMethodNotSupported: 'این نسخه از این روش هشدار پشتیبانی نمی‌کند'
ErrAlertSync: 'همگام‌سازی پیام هشدار ناموفق بود'
ErrWebhookTestFailed: 'لطفاً پیکربندی وب‌هوک را بررسی کنید. برای جزئیات، گزارش‌های سیستم را ببینید.'
# وظیفه - محیط اجرا
ErrInstallExtension: 'وظیفه نصب پسوند دیگری در حال اجراست'
# قالب ایمیل هشدار
PanelAlertTitle: "اعلان هشدار پنل"
TestAlertTitle: "ایمیل آزمایشی - تأیید اتصال ایمیل"
TestAlert: "این یک ایمیل آزمایشی برای تأیید صحت پیکربندی ارسال ایمیل شما است."
TestAlertTitle: "اعلان آزمایشی پنل"
TestAlertMessage: "این یک اعلان آزمایشی برای بررسی پیکربندی و اتصال کانال اعلان است."
LicenseExpirationAlert: "پنل {{ .node }}{{ .ip }}: مجوز در {{ .day }} روز منقضی می‌شود. برای مشاهده جزئیات وارد شوید."
CronJobFailedAlert: "پنل {{ .node }}{{ .ip }}: وظیفه زمان‌بندی {{ .name }} ناموفق بود. برای مشاهده جزئیات وارد شوید."
ClamAlert: "پنل {{ .node }}{{ .ip }}: اسکن ویروس {{ .num }} فایل آلوده پیدا کرد. برای مشاهده جزئیات وارد شوید."
+3 -2
View File
@@ -633,14 +633,15 @@ AppAttributesNotMatch: 'アプリケーションの種類または名前が一
# アラート
ErrAlertMethodNotSupported: 'このバージョンではこの通知方法は利用できません'
ErrAlertSync: 'アラーム情報の同期エラーです。確認してもう一度お試しください。'
ErrWebhookTestFailed: 'Webhook の設定を確認してください。詳細はシステムログを参照してください。'
# タスク - ランタイム
ErrInstallExtension: 'インストールタスクが進行中です、タスクが終了するのを待ってください'
# アラートメールテンプレート
PanelAlertTitle: 'パネルアラート通知'
TestAlertTitle: 'テストメール - メール接続の確認'
TestAlert: 'これはテストメールです。メール送信設定が正しく構成されているかを確認します。'
TestAlertTitle: 'パネルのテスト通知'
TestAlertMessage: 'これは通知チャネルの設定と接続性を確認するためのテスト通知です。'
LicenseExpirationAlert: 'あなたの {{ .node }}{{ .ip }} パネル、ライセンスは {{ .day }} 日後に期限切れになります。詳細はパネルにログインして'
CronJobFailedAlert: 'あなたの {{ .node }}{{ .ip }} パネル、スケジュールタスク {{ .name }} の実行に失敗しました。詳細はパネルにログインして'
ClamAlert: 'あなたの {{ .node }}{{ .ip }} パネル、ウイルススキャンタスクで {{ .num }} 個の感染ファイルを検出しました。詳細はパネルにログインして'
+3 -2
View File
@@ -633,14 +633,15 @@ AppAttributesNotMatch: '애플리케이션 유형 또는 이름이 일치하지
# 알림
ErrAlertMethodNotSupported: '현재 버전에서는 이 알림 방식을 지원하지 않습니다'
ErrAlertSync: '알람 정보 동기화 오류입니다. 확인하고 다시 시도하세요'
ErrWebhookTestFailed: 'Webhook 설정을 확인하세요. 자세한 원인은 시스템 로그를 확인하세요.'
# 작업 - 런타임
ErrInstallExtension: '이미 설치 작업이 진행 중입니다. 작업이 완료될 때까지 기다려 주세요.'
# 알림 메일 템플릿
PanelAlertTitle: '패널 알림 통지'
TestAlertTitle: '테스트 이메일 - 이메일 연결 확인'
TestAlert: '이것은 테스트 이메일이며 이메일 발송 설정이 올바른지 확인합니다.'
TestAlertTitle: '패널 테스트 알림'
TestAlertMessage: '알림 채널 설정 및 연결 상태를 확인하기 위한 테스트 알림입니다.'
LicenseExpirationAlert: '귀하의 {{ .node }}{{ .ip }} 패널, 라이센스가 {{ .day }}일 후에 만료됩니다. 자세한 내용은 패널에 로그인하십시오.'
CronJobFailedAlert: '귀하의 {{ .node }}{{ .ip }} 패널, 예약 작업 {{ .name }} 실행에 실패했습니다. 자세한 내용은 패널에 로그인하십시오.'
ClamAlert: '귀하의 {{ .node }}{{ .ip }} 패널, 바이러스 스캔 작업에서 {{ .num }}개의 감염 파일이 감지되었습니다. 자세한 내용은 패널에 로그인하십시오.'
+3 -2
View File
@@ -623,14 +623,15 @@ AppAttributesNotMatch: 'ປະເພດ ຫຼື ຊື່ຂອງແອັບ
#alert
ErrAlertSync: 'ຊິງຄ໌ຂໍ້ຄວາມແຈ້ງເຕືອນລົ້ມເຫຼວ'
ErrWebhookTestFailed: 'ກະລຸນາກວດສອບການຕັ້ງຄ່າ Webhook. ເບິ່ງບັນທຶກລະບົບສຳລັບລາຍລະອຽດ.'
#task - runtime
ErrInstallExtension: 'ມີວຽກຕິດຕັ້ງສ່ວນເສີມອື່ນກຳລັງເຮັດວຽກຢູ່'
# alert mail template
PanelAlertTitle: "ການແຈ້ງເຕືອນຈາກແຜງຄວບຄຸມ"
TestAlertTitle: "ອີເມວທົດສອບ - ກວດສອບການເຊື່ອມຕໍ່ອີເມວ"
TestAlert: "ນີ້ແມ່ນອີເມວທົດສອບເພື່ອຢືນຢັນວ່າການຕັ້ງຄ່າການສົ່ງອີເມວຂອງທ່ານຖືກຕ້ອງ."
TestAlertTitle: "ການແຈ້ງເຕືອນທົດສອບຈາກແຜງຄວບຄຸມ"
TestAlertMessage: "ນີ້ແມ່ນການແຈ້ງເຕືອນທົດສອບເພື່ອກວດສອບການຕັ້ງຄ່າ ແລະ ການເຊື່ອມຕໍ່ຂອງຊ່ອງທາງແຈ້ງເຕືອນ."
LicenseExpirationAlert: "ແຜງຄວບຄຸມ {{ .node }}{{ .ip }}: ໃບອະນຸຍາດຈະໝົດອາຍຸໃນ {{ .day }} ວັນ. ເຂົ້າສູ່ລະບົບເພື່ອເບິ່ງລາຍລະອຽດ."
CronJobFailedAlert: "ແຜງຄວບຄຸມ {{ .node }}{{ .ip }}: ວຽກທີ່ຕັ້ງເວລາ {{ .name }} ລົ້ມເຫຼວ. ເຂົ້າສູ່ລະບົບເພື່ອເບິ່ງລາຍລະອຽດ."
ClamAlert: "ແຜງຄວບຄຸມ {{ .node }}{{ .ip }}: ການສະແກນໄວຣັສພົບ {{ .num }} ໄຟລ໌ທີ່ຕິດເຊື້ອ. ເຂົ້າສູ່ລະບົບເພື່ອເບິ່ງລາຍລະອຽດ."
+3 -2
View File
@@ -633,14 +633,15 @@ AppAttributesNotMatch: 'Jenis atau nama aplikasi tidak sepadan'
# amaran
ErrAlertMethodNotSupported: 'Versi ini tidak menyokong kaedah amaran ini'
ErrAlertSync: 'Ralat penyegerakan maklumat penggera, sila semak dan cuba lagi'
ErrWebhookTestFailed: 'Sila semak konfigurasi Webhook. Lihat log sistem untuk butiran lanjut.'
# tugas - runtime
ErrInstallExtension: 'Tugas pemasangan sudah sedang berjalan, silakan tunggu tugas selesai'
# templat mel amaran
PanelAlertTitle: 'Notifikasi Amaran Panel'
TestAlertTitle: 'E-mel Ujian - Sahkan Sambungan E-mel'
TestAlert: 'Ini adalah e-mel ujian untuk mengesahkan konfigurasi penghantaran e-mel anda betul.'
TestAlertTitle: 'Notifikasi Ujian Panel'
TestAlertMessage: 'Ini ialah notifikasi ujian untuk mengesahkan konfigurasi dan kesambungan saluran notifikasi.'
LicenseExpirationAlert: 'Panel {{ .node }}{{ .ip }} Anda, lesen akan tamat tempoh dalam {{ .day }} hari. Sila log masuk ke panel untuk butiran lanjut.'
CronJobFailedAlert: 'Panel {{ .node }}{{ .ip }} Anda, tugas jadual {{ .name }} gagal dilaksanakan. Sila log masuk ke panel untuk butiran lanjut.'
ClamAlert: 'Panel {{ .node }}{{ .ip }} Anda, tugas imbasan virus mengesan {{ .num }} fail yang dijangkiti. Sila log masuk ke panel untuk butiran lanjut.'
+3 -2
View File
@@ -633,14 +633,15 @@ AppAttributesNotMatch: 'O tipo ou nome do aplicativo não corresponde'
# alerta
ErrAlertMethodNotSupported: 'Esta versão não oferece suporte a este método de alerta'
ErrAlertSync: 'Erro de sincronização de informações de alarme, verifique e'
ErrWebhookTestFailed: 'Verifique a configuração do Webhook. Consulte os logs do sistema para mais detalhes.'
# tarefa - runtime
ErrInstallExtension: 'Já existe uma tarefa de instalação em andamento, aguarde a conclusão da tarefa'
# modelo de e-mail de alerta
PanelAlertTitle: 'Notificação de Alerta do Painel'
TestAlertTitle: 'E-mail de Teste - Verificar Conectividade de E-mail'
TestAlert: 'Este é um e-mail de teste para verificar se sua configuração de envio de e-mails está correta.'
TestAlertTitle: 'Notificação de teste do painel'
TestAlertMessage: 'Esta é uma notificação de teste para verificar a configuração e a conectividade do canal de notificações.'
LicenseExpirationAlert: 'Seu Painel {{ .node }}{{ .ip }}, a licença expirará em {{ .day }} dias. Faça login no painel para obter detalhes.'
CronJobFailedAlert: 'Seu Painel {{ .node }}{{ .ip }}, a execução da tarefa agendada {{ .name }} falhou. Faça login no painel para obter detalhes.'
ClamAlert: 'Seu Painel {{ .node }}{{ .ip }}, a tarefa de verificação de vírus detectou {{ .num }} arquivos infectados. Faça login no painel para obter detalhes.'
+3 -2
View File
@@ -633,14 +633,15 @@ AppAttributesNotMatch: 'Тип или имя приложения не совп
# оповещение
ErrAlertMethodNotSupported: 'Эта версия не поддерживает данный способ оповещения'
ErrAlertSync: 'Ошибка синхронизации информации о тревоге, проверьте и повторите попытку'
ErrWebhookTestFailed: 'Проверьте конфигурацию Webhook. Подробности доступны в системных журналах.'
# задача - среда выполнения
ErrInstallExtension: 'Уже выполняется задача установки, подождите, пока задача завершится'
# шаблон письма оповещения
PanelAlertTitle: 'Уведомление панели'
TestAlertTitle: 'Тестовое письмо - проверка подключения'
TestAlert: 'Это тестовое письмо для проверки правильности настройки отправки писем.'
TestAlertTitle: 'Тестовое уведомление панели'
TestAlertMessage: 'Это тестовое уведомление для проверки настроек канала уведомлений и подключения.'
LicenseExpirationAlert: 'Ваш панель {{ .node }}{{ .ip }}, лицензия истечет через {{ .day }} дней. Войдите в панель для получения деталей.'
CronJobFailedAlert: 'Ваш панель {{ .node }}{{ .ip }}, выполнение запланированной задачи {{ .name }} не удалось. Войдите в панель для получения деталей.'
ClamAlert: 'Ваш панель {{ .node }}{{ .ip }}, задача сканирования на вирусы обнаружила {{ .num }} зараженных файлов. Войдите в панель для получения деталей.'
+3 -2
View File
@@ -633,14 +633,15 @@ AppAttributesNotMatch: 'Uygulama türü veya adı eşleşmiyor'
# uyarı
ErrAlertMethodNotSupported: 'Bu sürüm bu uyarı yöntemini desteklemiyor'
ErrAlertSync: 'Alarm bilgisi senkronizasyon hatası, lütfen kontrol edin ve tekrar deneyin'
ErrWebhookTestFailed: 'Lütfen Webhook yapılandırmasını kontrol edin. Ayrıntılar için sistem günlüklerine bakın.'
# görev - çalışma zamanı
ErrInstallExtension: 'Zaten devam eden bir kurulum görevi var, görevin bitmesini bekleyin'
# uyarı e-posta şablonu
PanelAlertTitle: 'Panel Uyarı Bildirimi'
TestAlertTitle: 'Test E-postası - E-posta Bağlantısını Doğrula'
TestAlert: 'Bu, e-posta gönderim ayarlarınızın doğru yapılandırıldığını doğrulamak için gönderilen bir test e-postasıdır.'
TestAlertTitle: 'Panel Test Bildirimi'
TestAlertMessage: 'Bu, bildirim kanalı yapılandırmasını ve bağlantısını doğrulamak için gönderilen bir test bildirimidir.'
LicenseExpirationAlert: 'Paneliniz {{ .node }}{{ .ip }}, lisans {{ .day }} gün içinde süresi dolacak. Detaylar için paneline giriş yapın.'
CronJobFailedAlert: 'Paneliniz {{ .node }}{{ .ip }}, zamanlanmış görev {{ .name }} çalıştırma başarısız oldu. Detaylar için paneline giriş yapın.'
ClamAlert: 'Paneliniz {{ .node }}{{ .ip }}, virüs tarama görevi {{ .num }} enfekte dosya tespit etti. Detaylar için paneline giriş yapın.'
+3 -2
View File
@@ -633,14 +633,15 @@ AppAttributesNotMatch: '應用程式類型或名稱不一致'
# 告警
ErrAlertMethodNotSupported: '當前版本不支援此告警方式'
ErrAlertSync: '警告訊息同步錯誤,請重試。'
ErrWebhookTestFailed: '請檢查 Webhook 設定,詳細原因請查看系統日誌'
# 任務 - 執行環境
ErrInstallExtension: '已有安裝任務正在進行,請等待任務結束'
# 告警郵件範本
PanelAlertTitle: '面板警示通知'
TestAlertTitle: '測試郵件 - 驗證信箱連通性'
TestAlert: '這是一封測試郵件,用於確認您的郵件發送設定是否正確。'
TestAlertTitle: '面板測試通知'
TestAlertMessage: '這是一則測試通知,用於驗證通知管道設定及連通性。'
LicenseExpirationAlert: '您的 {{ .node }}{{ .ip }} 面板,授權將於 {{ .day }} 天後到期,詳情請登入面板檢視。'
CronJobFailedAlert: '您的 {{ .node }}{{ .ip }} 面板,排程任務{{ .name }}執行失敗,詳情請登入面板檢視。'
ClamAlert: '您的 {{ .node }}{{ .ip }} 面板,病毒掃描任務偵測到 {{ .num }} 個感染檔案,詳情請登入面板檢視。'
+3 -2
View File
@@ -633,14 +633,15 @@ AppAttributesNotMatch: "应用类型或名称不一致"
# 告警
ErrAlertMethodNotSupported: "当前版本不支持此告警方式"
ErrAlertSync: "告警信息同步错误,请检查后重试"
ErrWebhookTestFailed: "请检查 Webhook 配置,详细原因请查看系统日志"
# 任务 - 运行环境
ErrInstallExtension: "已有安装任务正在进行,请等待任务结束"
# 告警邮件模板
PanelAlertTitle: "面板告警通知"
TestAlertTitle: "测试邮件 - 验证邮箱连通性"
TestAlert: "这是一封测试邮件,用于验证邮箱发件配置。"
TestAlertTitle: "面板测试通知"
TestAlertMessage: "这是一条测试通知,用于验证通知渠道配置及连通性。"
LicenseExpirationAlert: "您的 {{ .node }}{{ .ip }} 面板,许可证将在 {{ .day }} 天后到期,请登录面板查看详情。"
CronJobFailedAlert: "您的 {{ .node }}{{ .ip }} 面板,计划任务-{{ .name }}执行失败,请登录面板查看详情。"
ClamAlert: "您的 {{ .node }}{{ .ip }} 面板,病毒扫描任务发现 {{ .num }} 个感染文件,请登录面板查看详情。"
+5 -17
View File
@@ -139,33 +139,21 @@ func TestCustomWebhook(
resolved dto.AlertCustomWebhookResolvedConfig,
transport *http.Transport,
agentInfo *dto.AgentInfo,
lang string,
) (dto.AlertConfigTestResult, error) {
request, err := buildResolvedCustomWebhookRequest(resolved, webhook_sender.TemplateData{
Title: "1Panel Webhook Test",
Message: "This is a test notification from 1Panel.",
Title: i18n.GetMsgByKeyAndLang(lang, "TestAlertTitle"),
Message: i18n.GetMsgByKeyAndLang(lang, "TestAlertMessage"),
Type: "test",
NodeName: customWebhookNodeName(agentInfo),
Timestamp: customWebhookNow(),
}, transport)
if err != nil {
return dto.AlertConfigTestResult{}, err
return CustomWebhookTestResult(webhook_sender.Request{}, webhook_sender.Result{}, err), nil
}
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
return CustomWebhookTestResult(request, result, executeErr), nil
}
func buildResolvedCustomWebhookRequest(
@@ -0,0 +1,29 @@
package alert
import (
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/webhook_sender"
)
func CustomWebhookTestResult(request webhook_sender.Request, result webhook_sender.Result, executeErr error) dto.AlertConfigTestResult {
durationMillis := result.Duration.Milliseconds()
if result.Duration > 0 && durationMillis == 0 {
durationMillis = 1
}
testResult := dto.AlertConfigTestResult{
Success: executeErr == nil,
StatusCode: result.StatusCode,
Duration: durationMillis,
}
if executeErr == nil {
return testResult
}
if global.LOG != nil {
global.LOG.Warnf("webhook test failed: statusCode=%d durationMs=%d error=%q response=%q",
result.StatusCode, durationMillis,
webhook_sender.DiagnosticText(executeErr.Error(), request),
webhook_sender.DiagnosticText(result.Response, request))
}
return testResult
}
+99 -23
View File
@@ -142,7 +142,7 @@ func Execute(ctx context.Context, input Request) (Result, error) {
}
if err := validateResponse(input.Preset, resp.StatusCode, responseBody); err != nil {
return result, err
return result, safeResponseError(err, input, target)
}
return result, nil
}
@@ -332,6 +332,14 @@ func (b *proxyConnectionBody) Close() error {
}
func captureResponse(body []byte, input Request, target preparedTarget) string {
var document any
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.UseNumber()
if json.Valid(body) && decoder.Decode(&document) == nil {
if canonical, err := json.Marshal(document); err == nil {
body = canonical
}
}
response := strings.ToValidUTF8(string(body), "\uFFFD")
redactions := make([]string, 0, len(input.Headers)+8)
redactions = append(redactions, urlRedactionValues(input.URL)...)
@@ -345,17 +353,105 @@ func captureResponse(body []byte, input Request, target preparedTarget) string {
}
redactions = append(redactions, headerRedactionValues(name, value)...)
}
redactions = append(redactions, bodyRedactionValues(input)...)
for _, value := range append([]string(nil), redactions...) {
if encoded, err := json.Marshal(value); err == nil && len(encoded) > 2 {
redactions = append(redactions, string(encoded[1:len(encoded)-1]))
}
}
sort.SliceStable(redactions, func(i, j int) bool {
return len(redactions[i]) > len(redactions[j])
})
seen := make(map[string]struct{}, len(redactions))
replacements := make([]string, 0, len(redactions)*2)
for _, value := range redactions {
if value != "" {
response = strings.ReplaceAll(response, value, "[REDACTED]")
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
replacements = append(replacements, value, "[REDACTED]")
}
if len(replacements) > 0 {
response = strings.NewReplacer(replacements...).Replace(response)
}
return truncateUTF8(response, MaxCapturedResponseBytes)
}
func bodyRedactionValues(input Request) []string {
values := []string{string(input.Body)}
switch input.Format {
case BodyJSON:
var document any
decoder := json.NewDecoder(bytes.NewReader(input.Body))
decoder.UseNumber()
if decoder.Decode(&document) == nil {
var collect func(any, string)
collect = func(value any, key string) {
switch typed := value.(type) {
case string:
if len(typed) >= 4 || typed != "" && sensitiveBodyKey(key) {
values = append(values, typed)
}
case json.Number:
if sensitiveBodyKey(key) {
values = append(values, typed.String())
}
case map[string]any:
for childKey, child := range typed {
collect(child, childKey)
}
case []any:
for _, child := range typed {
collect(child, key)
}
}
}
collect(document, "")
}
case BodyForm:
if fields, err := url.ParseQuery(string(input.Body)); err == nil {
for key, fieldValues := range fields {
for _, value := range fieldValues {
if len(value) >= 4 || value != "" && sensitiveBodyKey(key) {
values = append(values, value)
encoded := url.QueryEscape(value)
percentSpaces := strings.ReplaceAll(encoded, "+", "%20")
values = append(values, encoded, percentSpaces, lowercasePercentEscapes(encoded), lowercasePercentEscapes(percentSpaces))
}
}
}
}
}
return values
}
func lowercasePercentEscapes(value string) string {
encoded := []byte(value)
for i := 0; i+2 < len(encoded); i++ {
if encoded[i] != '%' {
continue
}
for j := i + 1; j <= i+2; j++ {
if encoded[j] >= 'A' && encoded[j] <= 'F' {
encoded[j] += 'a' - 'A'
}
}
i += 2
}
return string(encoded)
}
func sensitiveBodyKey(key string) bool {
normalized := strings.ToLower(strings.NewReplacer("-", "", "_", "").Replace(key))
return normalized == "key" || normalized == "authorization" || normalized == "cookie" ||
strings.Contains(normalized, "password") || strings.Contains(normalized, "passwd") ||
strings.Contains(normalized, "token") || strings.Contains(normalized, "secret") ||
strings.Contains(normalized, "credential") || strings.Contains(normalized, "apikey")
}
func urlRedactionValues(rawURL string) []string {
trimmed := strings.TrimSpace(rawURL)
values := []string{trimmed}
@@ -745,23 +841,3 @@ func isPinnedWebhookAddress(host string, addresses []net.IP) bool {
}
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")
}
}
+180
View File
@@ -0,0 +1,180 @@
package webhook_sender
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
)
type responseRejection struct {
statusCode int
reason string
}
func DiagnosticText(value string, input Request) string {
return captureResponse([]byte(value), input, preparedTarget{})
}
func (e *responseRejection) Error() string {
return fmt.Sprintf("webhook provider rejected response (HTTP %d)", e.statusCode)
}
func validateResponse(preset Preset, statusCode int, body []byte) error {
if preset == "" {
preset = PresetGeneric
}
switch preset {
case PresetGeneric, PresetDiscord, PresetTeams, PresetSlack:
default:
return errors.New("unsupported webhook preset")
}
reason, failed := businessResponseFailure(body)
if preset == PresetSlack && reason == "" {
switch value := strings.TrimSpace(string(body)); value {
case "invalid_payload", "no_text", "invalid_token", "no_service", "no_service_id",
"no_active_hooks", "channel_not_found", "channel_is_archived",
"action_prohibited", "posting_to_general_channel_denied",
"team_disabled", "user_not_found", "too_many_attachments", "rollup_error":
reason = value
}
}
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
return &responseRejection{statusCode: statusCode, reason: reason}
}
if preset == PresetSlack {
if statusCode != http.StatusOK || strings.TrimSpace(string(body)) != "ok" {
return &responseRejection{statusCode: statusCode, reason: reason}
}
return nil
}
if failed {
return &responseRejection{statusCode: statusCode, reason: reason}
}
return nil
}
func businessResponseFailure(body []byte) (string, bool) {
var response map[string]json.RawMessage
if err := json.Unmarshal(body, &response); err != nil || response == nil {
return "", false
}
message := responseMessage(response)
if message == "" {
var errorText string
if json.Unmarshal(response["error"], &errorText) == nil {
message = strings.TrimSpace(errorText)
} else {
var nested map[string]json.RawMessage
if json.Unmarshal(response["error"], &nested) == nil {
message = responseMessage(nested)
}
}
}
for _, key := range []string{"errcode", "errCode", "error_code", "ErrorCode"} {
if raw, exists := response[key]; exists {
code, valid := responseCode(raw)
if !valid || !isSuccessResponseCode(code, false) {
return responseFailureReason(key, code, message), true
}
}
}
for _, key := range []string{"success", "ok"} {
var success bool
if raw, exists := response[key]; exists && string(raw) != "null" && json.Unmarshal(raw, &success) == nil && !success {
return responseFailureReason(key, "false", message), true
}
}
if raw, exists := response["error"]; exists {
var value any
if json.Unmarshal(raw, &value) == nil {
switch typed := value.(type) {
case string:
if strings.TrimSpace(typed) != "" {
return responseFailureReason("error", "", typed), true
}
case map[string]any:
if len(typed) != 0 {
return responseFailureReason("error", "", message), true
}
case bool:
if typed {
return responseFailureReason("error", "true", message), true
}
case float64:
if typed != 0 {
return responseFailureReason("error", strconv.FormatFloat(typed, 'g', -1, 64), message), true
}
}
}
}
if message != "" {
for _, key := range []string{"code", "Code", "StatusCode", "statusCode"} {
if raw, exists := response[key]; exists {
code, valid := responseCode(raw)
if !valid || !isSuccessResponseCode(code, true) {
return responseFailureReason(key, code, message), true
}
}
}
}
return message, false
}
func responseMessage(response map[string]json.RawMessage) string {
for _, key := range []string{"errmsg", "msg", "message", "description", "StatusMessage", "Message"} {
var message string
if json.Unmarshal(response[key], &message) == nil && strings.TrimSpace(message) != "" {
return strings.TrimSpace(message)
}
}
return ""
}
func responseCode(raw json.RawMessage) (string, bool) {
var code json.Number
if json.Unmarshal(raw, &code) == nil && code != "" {
return code.String(), true
}
var text string
if json.Unmarshal(raw, &text) == nil && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text), true
}
return "", false
}
func isSuccessResponseCode(code string, allowHTTP bool) bool {
number, err := strconv.ParseFloat(code, 64)
if err == nil {
return number == 0 || allowHTTP && number >= 200 && number < 300
}
return allowHTTP && (strings.EqualFold(code, "ok") || strings.EqualFold(code, "success"))
}
func responseFailureReason(field, code, message string) string {
reason := field
if code != "" {
reason += "=" + code
}
if message != "" {
reason += ": " + message
}
return reason
}
func safeResponseError(err error, input Request, target preparedTarget) error {
var rejection *responseRejection
if !input.CaptureResponse || !errors.As(err, &rejection) || rejection.reason == "" {
return err
}
reason := captureResponse([]byte(rejection.reason), input, target)
reason = strings.Join(strings.Fields(reason), " ")
if reason == "" {
return err
}
return fmt.Errorf("%w: %s", rejection, truncateUTF8(reason, 512))
}
+2 -2
View File
@@ -59,9 +59,9 @@ func (a *alertHelper) CreateTaskScanCustomWebhookAlertLog(alert dto.AlertDTO, al
return providers.DeliveryResult{}, err
}
func (a *alertHelper) TestCustomWebhook(config dto.AlertCustomWebhookResolvedConfig) (dto.AlertConfigTestResult, error) {
func (a *alertHelper) TestCustomWebhook(config dto.AlertCustomWebhookResolvedConfig, lang string) (dto.AlertConfigTestResult, error) {
transport, agentInfo := loadCommunityCustomWebhookContext()
return alertUtil.TestCustomWebhook(config, transport, agentInfo)
return alertUtil.TestCustomWebhook(config, transport, agentInfo, lang)
}
func (a *alertHelper) GetLicenseErrorAlert() (uint, error) {
+1 -1
View File
@@ -21,7 +21,7 @@ type AlertProvider interface {
}
type CustomWebhookTester interface {
TestCustomWebhook(config dto.AlertCustomWebhookResolvedConfig) (dto.AlertConfigTestResult, error)
TestCustomWebhook(config dto.AlertCustomWebhookResolvedConfig, lang string) (dto.AlertConfigTestResult, error)
}
type DeliveryResult struct {
+4 -4
View File
@@ -6965,16 +6965,16 @@ const message = {
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.',
'Unable to read the saved Webhook configuration. Re-enter the URL and any required sensitive headers, then test successfully before saving.',
clearSecret: 'Clear',
keepSecret: 'Keep unchanged',
secretCleared: 'This secret will be cleared when you save',
keepSecret: 'Restore saved value',
bodyType: 'Body Type',
bodyTemplate: 'Body Template',
formatJson: 'Format',
jsonFormatTooLarge: 'The formatted content exceeds the size limit. The original content has been kept.',
formFieldName: 'Field name',
formFieldValue: 'Field value',
addFormField: 'Add Field',
+5 -4
View File
@@ -7078,16 +7078,17 @@ const message = {
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.',
'No se puede leer la configuración guardada del Webhook. Vuelve a introducir la URL y las cabeceras sensibles necesarias y realiza una prueba satisfactoria antes de guardar.',
clearSecret: 'Borrar',
keepSecret: 'Mantener sin cambios',
secretCleared: 'Este secreto se borrará al guardar',
keepSecret: 'Restaurar valor guardado',
bodyType: 'Tipo de Body',
bodyTemplate: 'Plantilla del Body',
formatJson: 'Formatear',
jsonFormatTooLarge:
'El contenido formateado supera el límite de tamaño. Se ha conservado el contenido original.',
formFieldName: 'Nombre del campo',
formFieldValue: 'Valor del campo',
addFormField: 'Añadir campo',
+4 -4
View File
@@ -6925,16 +6925,16 @@ const message = {
webhookPreset: 'پیش‌تنظیم',
genericJsonPreset: 'JSON عمومی',
customPreset: 'سفارشی',
webhookUrlSecretHelper: 'URL وب‌هوک به‌صورت رمزگذاری‌شده ذخیره می‌شود و در اینجا قابل مشاهده و ویرایش است',
webhookPublicAddressHelper:
'فقط نشانی‌های HTTP/HTTPS قابل دسترس از اینترنت عمومی پشتیبانی می‌شوند؛ نشانی‌های محلی، خصوصی و رزروشده مسدود می‌شوند',
customWebhookRecoveryRequired:
'این پیکربندی نامعتبر یا مربوط به نسخه‌ای قدیمی است. URL وب‌هوک و Headerهای محرمانه موردنیاز را دوباره وارد و ذخیره کنید.',
'خواندن پیکربندی ذخیره‌شدهٔ وب‌هوک امکان‌پذیر نیست. نشانی و سرآیندهای حساس موردنیاز را دوباره وارد کنید و پس از موفقیت‌آمیز بودن آزمایش، ذخیره کنید.',
clearSecret: 'پاک کردن',
keepSecret: 'بدون تغییر',
secretCleared: 'این مقدار محرمانه هنگام ذخیره پاک می‌شود',
keepSecret: 'بازیابی مقدار ذخیره‌شده',
bodyType: 'نوع Body',
bodyTemplate: 'قالب Body',
formatJson: 'قالب‌بندی',
jsonFormatTooLarge: 'محتوای قالب‌بندی‌شده از محدودیت اندازه فراتر می‌رود. محتوای اصلی حفظ شده است.',
formFieldName: 'نام فیلد',
formFieldValue: 'مقدار فیلد',
addFormField: 'افزودن فیلد',
+4 -4
View File
@@ -6961,16 +6961,16 @@ const message = {
webhookPreset: 'プリセット',
genericJsonPreset: '汎用 JSON',
customPreset: 'カスタム',
webhookUrlSecretHelper: 'Webhook URL は暗号化して保存され、ここで表示および編集できます',
webhookPublicAddressHelper:
'公開ネットワークから到達可能な HTTP/HTTPS アドレスのみ対応し、ローカル、プライベート、予約済みアドレスは拒否されます',
customWebhookRecoveryRequired:
'この設定は無効か旧バージョンのものです。Webhook URL と必要なシークレット Header を再入力して保存してください。',
'保存済みの Webhook 設定を読み取れません。URL と必要な機密ヘッダーを再入力し、テストが成功してから保存してください。',
clearSecret: 'クリア',
keepSecret: '変更しない',
secretCleared: '保存するとこのシークレットはクリアされます',
keepSecret: '保存済みの値に戻す',
bodyType: 'Body タイプ',
bodyTemplate: 'Body テンプレート',
formatJson: '整形',
jsonFormatTooLarge: '整形後の内容がサイズ制限を超えるため、元の内容を保持しました。',
formFieldName: 'フィールド名',
formFieldValue: 'フィールド値',
addFormField: 'フィールドを追加',
+4 -4
View File
@@ -6821,16 +6821,16 @@ const message = {
webhookPreset: '프리셋',
genericJsonPreset: '일반 JSON',
customPreset: '사용자 지정',
webhookUrlSecretHelper: 'Webhook URL은 암호화되어 저장되며 여기에서 확인하고 편집할 수 있습니다',
webhookPublicAddressHelper:
'공개 네트워크에서 접근 가능한 HTTP/HTTPS 주소만 지원하며 로컬, 사설 및 예약 주소는 차단됩니다',
customWebhookRecoveryRequired:
'이 설정은 유효하지 않거나 이전 버전에서 생성되었습니다. Webhook URL과 필요한 비밀 Header를 다시 입력한 후 저장하세요.',
'저장된 Webhook 설정을 읽을 수 없습니다. URL과 필요한 민감한 헤더를 다시 입력하고 테스트에 성공한 후 저장하세요.',
clearSecret: '지우기',
keepSecret: '변경하지 않음',
secretCleared: '저장하면 이 비밀 값이 지워집니다',
keepSecret: '저장된 값 복원',
bodyType: 'Body 유형',
bodyTemplate: 'Body 템플릿',
formatJson: '서식 정리',
jsonFormatTooLarge: '서식을 정리한 내용이 크기 제한을 초과하여 원본 내용을 유지했습니다.',
formFieldName: '필드 이름',
formFieldValue: '필드 값',
addFormField: '필드 추가',
+4 -4
View File
@@ -6729,16 +6729,16 @@ const message = {
webhookPreset: 'ຄ່າສຳເລັດ',
genericJsonPreset: 'JSON ທົ່ວໄປ',
customPreset: 'ກຳນົດເອງ',
webhookUrlSecretHelper: 'Webhook URL ຈະຖືກເກັບແບບເຂົ້າລະຫັດ ແລະ ສາມາດເບິ່ງ ຫຼື ແກ້ໄຂໄດ້ຢູ່ບ່ອນນີ້',
webhookPublicAddressHelper:
'ຮອງຮັບສະເພາະທີ່ຢູ່ HTTP/HTTPS ທີ່ເຂົ້າເຖິງໄດ້ຈາກເຄືອຂ່າຍສາທາລະນະ; ທີ່ຢູ່ພາຍໃນ, ສ່ວນຕົວ ແລະ ສຳຮອງຈະຖືກບລັອກ',
customWebhookRecoveryRequired:
'ການຕັ້ງຄ່ານີ້ບໍ່ຖືກຕ້ອງ ຫຼື ມາຈາກເວີຊັນເກົ່າ. ກະລຸນາປ້ອນ Webhook URL ແລະ Header ລັບທີ່ຈຳເປັນໃໝ່ ແລ້ວບັນທຶກ.',
'ບໍ່ສາມາດອ່ານການຕັ້ງຄ່າ Webhook ທີ່ບັນທຶກໄວ້ໄດ້. ກະລຸນາປ້ອນ URL ແລະ Header ທີ່ມີຂໍ້ມູນລັບທີ່ຈຳເປັນໃໝ່ ແລ້ວບັນທຶກຫຼັງຈາກທົດສອບສຳເລັດ.',
clearSecret: 'ລ້າງ',
keepSecret: 'ບໍ່ປ່ຽນແປງ',
secretCleared: 'ຄ່າລັບນີ້ຈະຖືກລ້າງເມື່ອບັນທຶກ',
keepSecret: 'ກູ້ຄືນຄ່າທີ່ບັນທຶກໄວ້',
bodyType: 'ປະເພດ Body',
bodyTemplate: 'ແມ່ແບບ Body',
formatJson: 'ຈັດຮູບແບບ',
jsonFormatTooLarge: 'ເນື້ອຫາຫຼັງຈາກຈັດຮູບແບບເກີນຂີດຈຳກັດຂະໜາດ. ເນື້ອຫາເດີມໄດ້ຖືກເກັບໄວ້.',
formFieldName: 'ຊື່ຟິວ',
formFieldValue: 'ຄ່າຟິວ',
addFormField: 'ເພີ່ມຟິວ',
+4 -4
View File
@@ -7073,16 +7073,16 @@ const message = {
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.',
'Konfigurasi Webhook yang disimpan tidak dapat dibaca. Masukkan semula URL dan header sensitif yang diperlukan, kemudian pastikan ujian berjaya sebelum menyimpan.',
clearSecret: 'Kosongkan',
keepSecret: 'Kekalkan',
secretCleared: 'Rahsia ini akan dikosongkan apabila disimpan',
keepSecret: 'Pulihkan nilai tersimpan',
bodyType: 'Jenis Body',
bodyTemplate: 'Templat Body',
formatJson: 'Format',
jsonFormatTooLarge: 'Kandungan yang diformat melebihi had saiz. Kandungan asal telah dikekalkan.',
formFieldName: 'Nama medan',
formFieldValue: 'Nilai medan',
addFormField: 'Tambah medan',
+4 -4
View File
@@ -7114,16 +7114,16 @@ const message = {
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.',
'Não foi possível ler a configuração salva do Webhook. Informe novamente a URL e os cabeçalhos sensíveis necessários e execute um teste com sucesso antes de salvar.',
clearSecret: 'Limpar',
keepSecret: 'Manter inalterado',
secretCleared: 'Este segredo será limpo ao salvar',
keepSecret: 'Restaurar valor salvo',
bodyType: 'Tipo do Body',
bodyTemplate: 'Modelo do Body',
formatJson: 'Formatar',
jsonFormatTooLarge: 'O conteúdo formatado excede o limite de tamanho. O conteúdo original foi mantido.',
formFieldName: 'Nome do campo',
formFieldValue: 'Valor do campo',
addFormField: 'Adicionar campo',
+4 -5
View File
@@ -7084,17 +7084,16 @@ const message = {
webhookPreset: 'Предустановка',
genericJsonPreset: 'Универсальный JSON',
customPreset: 'Пользовательский',
webhookUrlSecretHelper:
'URL Webhook хранится в зашифрованном виде; здесь его можно просматривать и изменять',
webhookPublicAddressHelper:
'Поддерживаются только общедоступные адреса HTTP/HTTPS; локальные, частные и зарезервированные адреса блокируются',
customWebhookRecoveryRequired:
'Эта конфигурация недействительна или создана в старой версии. Повторно укажите URL Webhook и необходимые секретные заголовки, затем сохраните.',
'Не удалось прочитать сохранённую конфигурацию Webhook. Повторно введите URL и необходимые конфиденциальные заголовки, затем выполните успешный тест перед сохранением.',
clearSecret: 'Очистить',
keepSecret: 'Не изменять',
secretCleared: 'Этот секрет будет очищен при сохранении',
keepSecret: 'Восстановить сохранённое значение',
bodyType: 'Тип Body',
bodyTemplate: 'Шаблон Body',
formatJson: 'Форматировать',
jsonFormatTooLarge: 'Форматированный текст превышает ограничение размера. Исходный текст сохранён.',
formFieldName: 'Имя поля',
formFieldValue: 'Значение поля',
addFormField: 'Добавить поле',
+4 -4
View File
@@ -7087,16 +7087,16 @@ const message = {
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.',
'Kaydedilmiş Webhook yapılandırması okunamıyor. URL ve gerekli hassas başlıkları yeniden girin, ardından test başarılı olduktan sonra kaydedin.',
clearSecret: 'Temizle',
keepSecret: 'Değiştirme',
secretCleared: 'Kaydettiğinizde bu gizli değer temizlenecek',
keepSecret: 'Kayıtlı değeri geri yükle',
bodyType: 'Body türü',
bodyTemplate: 'Body şablonu',
formatJson: 'Biçimlendir',
jsonFormatTooLarge: 'Biçimlendirilmiş içerik boyut sınırını aşıyor. Orijinal içerik korundu.',
formFieldName: 'Alan adı',
formFieldValue: 'Alan değeri',
addFormField: 'Alan ekle',
+4 -4
View File
@@ -6464,15 +6464,15 @@ const message = {
webhookPreset: '預設',
genericJsonPreset: '通用 JSON',
customPreset: '自訂',
webhookUrlSecretHelper: 'Webhook 位址會加密儲存,可在此檢視與編輯',
webhookPublicAddressHelper: '僅支援可由公網存取的 HTTP/HTTPS 位址;本機、內網和保留位址會被攔截',
customWebhookRecoveryRequired:
'目前設定已失效或來自舊版本,請重新填寫 Webhook 位址及所需的敏感 Header 後儲存',
'無法讀取已儲存的 Webhook 設定,請重新填寫位址和所需的敏感 Header,測試通過後儲存。',
clearSecret: '清除',
keepSecret: '保持不變',
secretCleared: '儲存後將清除此敏感值',
keepSecret: '還原原值',
bodyType: 'Body 類型',
bodyTemplate: 'Body 範本',
formatJson: '格式化',
jsonFormatTooLarge: '格式化後內容超出長度限制,已保留原內容。',
formFieldName: '欄位名稱',
formFieldValue: '欄位值',
addFormField: '新增欄位',
+4 -4
View File
@@ -6518,15 +6518,15 @@ const message = {
webhookPreset: '预设',
genericJsonPreset: '通用 JSON',
customPreset: '自定义',
webhookUrlSecretHelper: 'Webhook 地址加密存储,可在此查看和编辑',
webhookPublicAddressHelper: '仅支持公网可访问的 HTTP/HTTPS 地址;本机、内网和保留地址会被拦截',
customWebhookRecoveryRequired:
'当前配置已失效或来自旧版本,请重新填写 Webhook 地址及所需的敏感 Header 后保存',
'无法读取已保存的 Webhook 配置,请重新填写地址和所需的敏感 Header,测试通过后保存。',
clearSecret: '清除',
keepSecret: '保持不变',
secretCleared: '保存后将清除此敏感值',
keepSecret: '恢复原值',
bodyType: 'Body 类型',
bodyTemplate: 'Body 模板',
formatJson: '格式化',
jsonFormatTooLarge: '格式化后内容超出长度限制,已保留原内容。',
formFieldName: '字段名',
formFieldValue: '字段值',
addFormField: '添加字段',
@@ -31,11 +31,35 @@
</el-select>
</el-form-item>
<el-form-item :label="$t('xpack.alert.webhookUrl')" :error="errorFor('url')">
<el-form-item class="webhook-url-item" :label="$t('xpack.alert.webhookUrl')" :error="errorFor('url')">
<template #label>
<div class="flex w-full flex-wrap items-center justify-between gap-x-3">
<span>{{ $t('xpack.alert.webhookUrl') }}</span>
<div class="flex flex-wrap items-center gap-2">
<el-button
v-if="modelValue.url.configured && modelValue.url.action !== 'keep'"
link
type="primary"
@click.prevent="keepUrl"
>
{{ $t('xpack.alert.keepSecret') }}
</el-button>
<el-button
v-if="allowClearUrl && modelValue.url.configured && modelValue.url.action !== 'clear'"
link
type="danger"
@click.prevent="clearUrl"
>
{{ $t('xpack.alert.clearSecret') }}
</el-button>
</div>
</div>
</template>
<template v-if="modelValue.url.action === 'keep'">
<div class="secret-editor">
<el-input
:model-value="secretEditorValue(modelValue.url)"
:aria-label="$t('xpack.alert.webhookUrl')"
type="password"
show-password
autocomplete="new-password"
@@ -43,18 +67,13 @@
: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=""
:aria-label="$t('xpack.alert.webhookUrl')"
type="password"
show-password
autocomplete="new-password"
@@ -62,17 +81,13 @@
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"
:aria-label="$t('xpack.alert.webhookUrl')"
type="password"
show-password
autocomplete="new-password"
@@ -80,15 +95,8 @@
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>
@@ -101,7 +109,22 @@
<span class="input-help">POST · {{ derivedContentType }}</span>
</el-form-item>
<el-form-item :label="$t('xpack.alert.bodyTemplate')" :error="errorFor('body')">
<el-form-item class="body-template-item" :label="$t('xpack.alert.bodyTemplate')" :error="errorFor('body')">
<template #label>
<div class="flex w-full min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-1">
<span>{{ $t('xpack.alert.bodyTemplate') }}</span>
<el-button
v-if="modelValue.body.type === 'json'"
class="min-h-11 shrink-0 sm:min-h-6"
link
type="primary"
:disabled="!modelValue.body.template.trim()"
@click.prevent="formatJsonBody"
>
{{ $t('xpack.alert.formatJson') }}
</el-button>
</div>
</template>
<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">
@@ -135,10 +158,16 @@
<el-input
v-else
ref="bodyTemplateInputRef"
class="body-template-editor"
:model-value="modelValue.body.template"
:aria-label="$t('xpack.alert.bodyTemplate')"
type="textarea"
:rows="modelValue.body.type === 'json' ? 10 : 6"
:rows="modelValue.body.type === 'json' ? 12 : 7"
:maxlength="CUSTOM_WEBHOOK_LIMITS.body"
:wrap="modelValue.body.type === 'json' ? 'off' : 'soft'"
:spellcheck="false"
autocapitalize="off"
autocomplete="off"
resize="vertical"
@focus="bodyTemplateFocused = true"
@update:model-value="updateBodyTemplate"
@@ -188,11 +217,6 @@
</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"
@@ -228,6 +252,7 @@
<el-button
v-if="header.configured && header.action !== 'keep'"
link
type="primary"
@click="setHeaderSecretAction(index, 'keep')"
>
{{ $t('xpack.alert.keepSecret') }}
@@ -281,6 +306,7 @@
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { ElInput, ElMessageBox } from 'element-plus';
import i18n from '@/lang';
import { MsgError } from '@/utils/message';
import {
CUSTOM_WEBHOOK_VARIABLES,
CUSTOM_WEBHOOK_LIMITS,
@@ -294,6 +320,7 @@ import {
contentTypeForBodyType,
createCustomWebhookFormField,
createCustomWebhookHeader,
formatCustomWebhookJsonTemplate,
insertCustomWebhookVariable,
isCustomWebhookSecretHeader,
isCustomWebhookPresetPristine,
@@ -426,6 +453,33 @@ const updateBodyTemplate = (template: string) => {
emitValue(updateCustomWebhookBodyTemplate(props.modelValue, template));
};
const formatJsonBody = () => {
if (props.modelValue.body.type !== 'json') return;
const result = formatCustomWebhookJsonTemplate(props.modelValue.body.template);
if (!result.success) {
MsgError(
i18n.global.t(
result.error === 'bodyTooLarge'
? 'xpack.alert.jsonFormatTooLarge'
: 'xpack.alert.customWebhookValidation.jsonInvalid',
),
);
return;
}
if (result.template === props.modelValue.body.template) return;
updateBodyTemplate(result.template);
void nextTick(() => {
bodyTemplateInputRef.value?.focus();
const textarea = bodyTemplateInputRef.value?.textarea;
if (textarea) {
textarea.setSelectionRange(0, 0);
textarea.scrollTop = 0;
textarea.scrollLeft = 0;
}
bodyTemplateFocused.value = true;
});
};
const addFormField = () => {
emitValue({
...props.modelValue,
@@ -571,7 +625,6 @@ const errorFor = (field: string): string => {
gap: 8px;
}
.secret-editor__actions,
.header-card__footer,
.header-card__secret-actions {
display: flex;
@@ -580,6 +633,22 @@ const errorFor = (field: string): string => {
gap: 8px;
}
.webhook-url-item :deep(.el-form-item__label),
.body-template-item :deep(.el-form-item__label) {
width: 100%;
height: auto;
min-height: 22px;
}
.body-template-editor :deep(.el-textarea__inner) {
padding: 12px;
font-family: var(--el-font-family-monospace, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);
font-size: 13px;
line-height: 1.6;
tab-size: 2;
background-color: var(--el-fill-color-extra-light);
}
.key-value-list,
.header-list {
display: flex;
@@ -664,7 +733,7 @@ const errorFor = (field: string): string => {
min-height: 44px;
}
.secret-editor__actions > .el-button,
.webhook-url-item :deep(.el-form-item__label .el-button),
.key-value-row > .el-button {
min-height: 44px;
}
@@ -13,6 +13,41 @@ export type CustomWebhookPreset = 'genericJson' | 'slack' | 'discord' | 'teamsWo
export type CustomWebhookBodyType = 'json' | 'form' | 'text';
export type CustomWebhookSecretAction = 'keep' | 'replace' | 'clear';
export interface CustomWebhookTestState {
attempt: number;
pending: boolean;
passed: boolean;
}
export const createCustomWebhookTestState = (): CustomWebhookTestState => ({
attempt: 0,
pending: false,
passed: false,
});
export const invalidateCustomWebhookTest = (state: CustomWebhookTestState): void => {
state.attempt += 1;
state.pending = false;
state.passed = false;
};
export const beginCustomWebhookTest = (state: CustomWebhookTestState): number => {
invalidateCustomWebhookTest(state);
state.pending = true;
return state.attempt;
};
export const completeCustomWebhookTest = (
state: CustomWebhookTestState,
attempt: number,
success: boolean,
): boolean => {
if (attempt !== state.attempt || !state.pending) return false;
state.pending = false;
state.passed = success;
return true;
};
export interface CustomWebhookSecretView {
configured: boolean;
masked?: string;
@@ -466,6 +501,51 @@ const isJsonTemplateValid = (value: string): boolean => {
}
};
export type CustomWebhookJsonFormatResult =
{ success: true; template: string } | { success: false; error: 'jsonInvalid' | 'bodyTooLarge' };
export const formatCustomWebhookJsonTemplate = (template: string): CustomWebhookJsonFormatResult => {
if (!isJsonTemplateValid(template)) return { success: false, error: 'jsonInvalid' };
const tokens = template.matchAll(/"(?:\\.|[^"\\])*"|[{}[\],:]|[^\s{}[\],:]+/g);
const parts: string[] = [];
const encoder = new TextEncoder();
let remaining = CUSTOM_WEBHOOK_LIMITS.body;
let depth = 0;
let previous = '';
const append = (value: string): boolean => {
if (value.length > remaining) return false;
const size = encoder.encode(value).length;
if (size > remaining) return false;
remaining -= size;
parts.push(value);
return true;
};
const appendIndent = (): boolean => {
const size = 1 + depth * 2;
if (size > remaining) return false;
remaining -= size;
parts.push('\n' + ' '.repeat(depth));
return true;
};
for (const [token] of tokens) {
if (token === '}' || token === ']') {
depth -= 1;
if (previous !== '{' && previous !== '[' && !appendIndent()) {
return { success: false, error: 'bodyTooLarge' };
}
} else if (previous === '{' || previous === '[' || previous === ',') {
if (!appendIndent()) return { success: false, error: 'bodyTooLarge' };
}
if (!append(token === ':' ? ': ' : token)) return { success: false, error: 'bodyTooLarge' };
if (token === '{' || token === '[') depth += 1;
previous = token;
}
return { success: true, template: parts.join('') };
};
const hasTemplateJsonKey = (value: string): boolean => {
try {
const visit = (node: unknown): boolean => {
@@ -166,8 +166,12 @@ import { Alert } from '@/api/interface/alert';
import CustomWebhookForm from './custom-webhook-form.vue';
import {
CustomWebhookValidationIssue,
beginCustomWebhookTest,
completeCustomWebhookTest,
createCustomWebhookTestState,
createDefaultCustomWebhookDraft,
hydrateCustomWebhookDraft,
invalidateCustomWebhookTest,
serializeCustomWebhookDraft,
validateCustomWebhookDraft,
} from './custom-webhook';
@@ -235,7 +239,8 @@ const defaultEmailForm = {
const drawerVisible = ref(false);
const loading = ref(false);
const testLoading = ref(false);
const customWebhookTestState = reactive(createCustomWebhookTestState());
const testLoading = computed(() => customWebhookTestState.pending);
const isEdit = ref(false);
const isOK = ref(false);
const emailRevision = ref(0);
@@ -273,14 +278,11 @@ const form = reactive({
});
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 customWebhookTestPassed = computed(() => customWebhookTestState.passed);
const customWebhookSaveAllowed = computed(
() => customWebhookTestPassed.value || (form.status === 'Disable' && form.customWebhook.url.action === 'clear'),
() =>
!testLoading.value &&
(customWebhookTestPassed.value || (form.status === 'Disable' && form.customWebhook.url.action === 'clear')),
);
const drawerHeader = computed(() => {
@@ -421,7 +423,7 @@ const acceptParams = (params: DrawerProps): void => {
isOK.value = false;
customWebhookValidationIssues.value = [];
testedCustomWebhookRevision.value = null;
invalidateCustomWebhookTest(customWebhookTestState);
drawerVisible.value = true;
void loadAlertConfigs();
};
@@ -450,7 +452,7 @@ const onTypeChange = (type: string) => {
}
isOK.value = false;
customWebhookValidationIssues.value = [];
testedCustomWebhookRevision.value = null;
invalidateCustomWebhookTest(customWebhookTestState);
formRef.value?.clearValidate();
};
@@ -554,7 +556,9 @@ const onSave = async (formEl: FormInstance | undefined) => {
}
if (form.type === 'custom') {
if (!customWebhookSaveAllowed.value) return;
const testedAttempt = customWebhookTestState.attempt;
if (await validateCustomWebhook(formEl, form.status === 'Disable')) {
if (testedAttempt !== customWebhookTestState.attempt || !customWebhookSaveAllowed.value) return;
try {
await saveAlertConfig();
} catch {
@@ -576,11 +580,9 @@ const onSave = async (formEl: FormInstance | undefined) => {
const onTest = async (formEl: FormInstance | undefined) => {
if (!formEl) return;
if (form.type === 'custom') {
testedCustomWebhookRevision.value = null;
if (!(await validateCustomWebhook(formEl))) return;
testLoading.value = true;
const attempt = beginCustomWebhookTest(customWebhookTestState);
try {
const testedRevision = customWebhookRevision.value;
if (!(await validateCustomWebhook(formEl)) || attempt !== customWebhookTestState.attempt) return;
const config = JSON.stringify(serializeCustomWebhookDraft(form.customWebhook));
const res = await TestCustomAlertConfig({
...(form.id ? { id: form.id } : {}),
@@ -589,23 +591,17 @@ const onTest = async (formEl: FormInstance | undefined) => {
});
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');
const success = typeof raw === 'boolean' ? raw : result?.success === true;
if (!completeCustomWebhookTest(customWebhookTestState, attempt, success)) return;
if (success) {
if (testedRevision !== customWebhookRevision.value) {
MsgWarning(i18n.global.t('xpack.alert.testResultStale'));
MsgSuccess(i18n.global.t('xpack.alert.alertTestOk'));
return;
}
testedCustomWebhookRevision.value = testedRevision;
MsgSuccess(message);
return;
}
MsgError(message);
MsgError(result?.message || i18n.global.t('xpack.alert.alertTestFailed'));
} catch {
return;
} finally {
testLoading.value = false;
completeCustomWebhookTest(customWebhookTestState, attempt, false);
}
return;
}
@@ -669,8 +665,7 @@ watch(
() => {
if (form.type !== 'custom') return;
customWebhookValidationIssues.value = [];
customWebhookRevision.value += 1;
testedCustomWebhookRevision.value = null;
invalidateCustomWebhookTest(customWebhookTestState);
},
{ deep: true, flush: 'sync' },
);
@@ -698,7 +693,7 @@ watch(
const handleClose = () => {
isOK.value = false;
testedCustomWebhookRevision.value = null;
invalidateCustomWebhookTest(customWebhookTestState);
drawerVisible.value = false;
};