feat: improve alert config handling (#13001)

This commit is contained in:
2026-06-10 21:48:54 +08:00
committed by GitHub
parent cfdf448765
commit e66000ffd2
16 changed files with 224 additions and 32 deletions
+167 -17
View File
@@ -1,6 +1,9 @@
package repo
import (
"encoding/json"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
@@ -353,56 +356,203 @@ var singletonTypes = map[string]bool{
func (a *AlertRepo) SyncAll(data []model.AlertConfig) error {
tx := global.AlertDB.Begin()
if tx.Error != nil {
return tx.Error
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
panic(r)
}
}()
var oldConfigs []model.AlertConfig
_ = tx.Find(&oldConfigs).Error
if err := tx.Find(&oldConfigs).Error; err != nil {
tx.Rollback()
return err
}
usedConfigIDs, err := loadUsedAlertConfigIDs(tx)
if err != nil {
tx.Rollback()
return err
}
oldConfigMap := make(map[string]uint)
nonSingletonTypes := make(map[string]struct{})
oldConfigByType := make(map[string][]model.AlertConfig)
oldConfigByKey := make(map[string][]model.AlertConfig)
consumedConfigIDs := make(map[uint]struct{})
for _, item := range oldConfigs {
if singletonTypes[item.Type] {
oldConfigMap[item.Type] = item.ID
continue
}
nonSingletonTypes[item.Type] = struct{}{}
}
for _, item := range data {
if !singletonTypes[item.Type] {
nonSingletonTypes[item.Type] = struct{}{}
}
}
for itemType := range nonSingletonTypes {
if err := tx.Where("type = ?", itemType).Delete(&model.AlertConfig{}).Error; err != nil {
tx.Rollback()
return err
}
oldConfigByType[item.Type] = append(oldConfigByType[item.Type], item)
oldConfigByKey[alertConfigSyncKey(item)] = append(oldConfigByKey[alertConfigSyncKey(item)], item)
}
for _, item := range data {
if singletonTypes[item.Type] {
if val, ok := oldConfigMap[item.Type]; ok {
item.ID = val
delete(oldConfigMap, item.Type)
consumedConfigIDs[item.ID] = struct{}{}
} else {
item.ID = 0
}
if err := tx.Model(model.AlertConfig{}).Where("id = ?", item.ID).Save(&item).Error; err != nil {
if item.ID == 0 {
if err := tx.Create(&item).Error; err != nil {
tx.Rollback()
return err
}
} else if err := tx.Save(&item).Error; err != nil {
tx.Rollback()
return err
}
continue
}
key := alertConfigSyncKey(item)
if matched, ok := popAlertConfigByKey(oldConfigByKey, key); ok {
item.ID = matched.ID
consumedConfigIDs[item.ID] = struct{}{}
if err := tx.Save(&item).Error; err != nil {
tx.Rollback()
return err
}
deleteAlertConfigByID(oldConfigByType, matched.ID)
continue
}
if matched, ok := popUnusedAlertConfigByType(oldConfigByType, usedConfigIDs, item.Type); ok {
item.ID = matched.ID
consumedConfigIDs[item.ID] = struct{}{}
if err := tx.Save(&item).Error; err != nil {
tx.Rollback()
return err
}
continue
}
item.ID = 0
if err := tx.Create(&item).Error; err != nil {
tx.Rollback()
return err
}
}
for _, id := range oldConfigMap {
if err := tx.Where("id = ?", id).Delete(&model.AlertConfig{}).Error; err != nil {
for _, item := range oldConfigs {
if _, used := usedConfigIDs[item.ID]; used {
continue
}
if _, kept := consumedConfigIDs[item.ID]; kept {
continue
}
if err := tx.Where("id = ?", item.ID).Delete(&model.AlertConfig{}).Error; err != nil {
tx.Rollback()
return err
}
}
if err := tx.Commit().Error; err != nil {
tx.Rollback()
return err
}
return nil
}
func loadUsedAlertConfigIDs(tx *gorm.DB) (map[uint]struct{}, error) {
var alerts []model.Alert
if err := tx.Select("method").Find(&alerts).Error; err != nil {
return nil, err
}
usedIDs := make(map[uint]struct{})
for _, alert := range alerts {
for _, item := range strings.Split(alert.Method, ",") {
item = strings.TrimSpace(item)
if item == "" {
continue
}
id, err := strconv.ParseUint(item, 10, 64)
if err != nil {
continue
}
usedIDs[uint(id)] = struct{}{}
}
}
return usedIDs, nil
}
func alertConfigSyncKey(item model.AlertConfig) string {
return item.Type + "::" + normalizeAlertConfigJSON(item.Config)
}
func normalizeAlertConfigJSON(config string) string {
trimmed := strings.TrimSpace(config)
if trimmed == "" {
return ""
}
var data any
if err := json.Unmarshal([]byte(trimmed), &data); err != nil {
return trimmed
}
buf, err := json.Marshal(data)
if err != nil {
return trimmed
}
return string(buf)
}
func popAlertConfigByKey(configMap map[string][]model.AlertConfig, key string) (model.AlertConfig, bool) {
items := configMap[key]
if len(items) == 0 {
return model.AlertConfig{}, false
}
item := items[0]
if len(items) == 1 {
delete(configMap, key)
} else {
configMap[key] = items[1:]
}
return item, true
}
func popUnusedAlertConfigByType(configMap map[string][]model.AlertConfig, usedConfigIDs map[uint]struct{}, configType string) (model.AlertConfig, bool) {
items := configMap[configType]
if len(items) == 0 {
return model.AlertConfig{}, false
}
for idx, item := range items {
if _, used := usedConfigIDs[item.ID]; used {
continue
}
if idx == 0 {
if len(items) == 1 {
delete(configMap, configType)
} else {
configMap[configType] = items[1:]
}
} else {
configMap[configType] = append(items[:idx], items[idx+1:]...)
}
return item, true
}
return model.AlertConfig{}, false
}
func deleteAlertConfigByID(configMap map[string][]model.AlertConfig, id uint) {
for key, items := range configMap {
for idx, item := range items {
if item.ID != id {
continue
}
if len(items) == 1 {
delete(configMap, key)
} else {
configMap[key] = append(items[:idx], items[idx+1:]...)
}
return
}
}
}
+37 -1
View File
@@ -512,6 +512,9 @@ func (a AlertService) UpdateAlertConfig(req dto.AlertConfigUpdate, operator stri
if err := a.checkAlertConfigDisplayNameUnique(req); err != nil {
return err
}
if err := a.checkAlertConfigSMSPhoneUnique(req); err != nil {
return err
}
if req.ID != 0 {
upMap := make(map[string]interface{})
upMap["id"] = req.ID
@@ -538,6 +541,29 @@ func (a AlertService) UpdateAlertConfig(req dto.AlertConfigUpdate, operator stri
return nil
}
func (a AlertService) checkAlertConfigSMSPhoneUnique(req dto.AlertConfigUpdate) error {
if req.Type != constant.SMSConfig {
return nil
}
phone := alertConfigSMSPhone(req.Config)
configs, err := alertRepo.AlertConfigList(alertRepo.WithByType(req.Type))
if err != nil {
return err
}
for _, config := range configs {
if req.ID != 0 && config.ID == req.ID {
continue
}
if alertConfigSMSPhone(config.Config) == phone {
return buserr.New("ErrAlertConfigPhoneExist")
}
}
return nil
}
func (a AlertService) checkAlertConfigDisplayNameUnique(req dto.AlertConfigUpdate) error {
displayName := alertConfigDisplayName(req.Type, req.Config)
if displayName == "" {
@@ -604,7 +630,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:
case constant.Email, constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Bark, constant.SMS:
var cfg struct {
DisplayName string `json:"displayName"`
}
@@ -617,6 +643,16 @@ func alertConfigDisplayName(configType, configData string) string {
}
}
func alertConfigSMSPhone(configData string) string {
var cfg struct {
Phone string `json:"phone"`
}
if err := json.Unmarshal([]byte(configData), &cfg); err != nil {
return ""
}
return strings.TrimSpace(cfg.Phone)
}
func (a AlertService) DeleteAlertConfig(id uint) error {
_, err := alertRepo.GetConfigById(id)
if err != nil {
+1
View File
@@ -78,6 +78,7 @@ ErrBackupCheck: 'Backup account connectivity test failed: {{ .err }}'
ErrBackupLocalDelete: 'Local backup accounts cannot be deleted'
ErrBackupLocalCreate: 'Local backup accounts cannot be created'
ErrAlertConfigInUse: 'Alert configuration is in use and cannot be deleted'
ErrAlertConfigPhoneExist: 'Phone number already exists'
#app
ErrPortInUsed: '{{ .detail }} port is already occupied!'
+1
View File
@@ -79,6 +79,7 @@ ErrBackupCheck: 'Conexión de respaldo falló: {{ .err }}'
ErrBackupLocalDelete: 'No se puede eliminar cuentas de respaldo locales'
ErrBackupLocalCreate: 'No se pueden crear cuentas de respaldo locales'
ErrAlertConfigInUse: 'La configuración de alertas está en uso y no se puede eliminar'
ErrAlertConfigPhoneExist: 'El numero de telefono ya existe'
ErrPortInUsed: 'El puerto {{ .detail }} ya está ocupado'
ErrAppLimit: 'El número de aplicaciones instaladas ha superado el límite'
ErrNotInstall: 'Aplicación no instalada'
+1
View File
@@ -79,6 +79,7 @@ ErrBackupCheck: '接続テストに失敗しました: {{ .err }}'
ErrBackupLocalDelete: 'ローカルバックアップは削除できません'
ErrBackupLocalCreate: 'ローカルバックアップは作成できません'
ErrAlertConfigInUse: 'アラート設定は使用中のため削除できません'
ErrAlertConfigPhoneExist: '電話番号はすでに存在します'
ErrPortInUsed: '{{ .detail }} ポートはすでに使用されています'
ErrAppLimit: 'インストールされているアプリケーションの数が制限を超えました'
ErrNotInstall: 'アプリケーションがインストールされていません'
+1
View File
@@ -79,6 +79,7 @@ ErrBackupCheck: '연결 테스트 실패: {{ .err }}'
ErrBackupLocalDelete: '로컬 백업은 삭제할 수 없습니다'
ErrBackupLocalCreate: '로컬 백업은 만들 수 없습니다'
ErrAlertConfigInUse: '경고 설정이 사용 중이므로 삭제할 수 없습니다'
ErrAlertConfigPhoneExist: '전화번호가 이미 존재합니다'
ErrPortInUsed: '{{ .detail }} 포트가 이미 사용 중입니다'
ErrAppLimit: '설치된 애플리케이션 수가 한도를 초과했습니다'
ErrNotInstall: '응용 프로그램이 설치되지 않았습니다'
+1
View File
@@ -79,6 +79,7 @@ ErrBackupCheck: 'Ujian sambungan gagal: {{ .err }}'
ErrBackupLocalDelete: 'Tidak boleh padam sandaran tempatan'
ErrBackupLocalCreate: 'Tidak boleh buat sandaran tempatan'
ErrAlertConfigInUse: 'Konfigurasi amaran sedang digunakan dan tidak boleh dipadamkan'
ErrAlertConfigPhoneExist: 'Nombor telefon sudah wujud'
ErrPortInUsed: 'Port {{ .detail }} sudah diduduki'
ErrAppLimit: 'Bilangan aplikasi yang dipasang telah melebihi had'
ErrNotInstall: 'Aplikasi tidak dipasang'
+1
View File
@@ -79,6 +79,7 @@ ErrBackupCheck: 'Teste de conexão falhou: {{ .err }}'
ErrBackupLocalDelete: 'Não é permitido excluir contas locais'
ErrBackupLocalCreate: 'Não é permitido criar contas locais'
ErrAlertConfigInUse: 'A configuração de alerta está em uso e não pode ser excluída'
ErrAlertConfigPhoneExist: 'O numero de telefone ja existe'
ErrPortInUsed: 'A porta {{ .detail }} já está ocupada'
ErrAppLimit: 'O número de aplicativos instalados excedeu o limite'
ErrNotInstall: 'Aplicativo não instalado'
+1
View File
@@ -79,6 +79,7 @@ ErrBackupCheck: 'Проверка подключения не удалась: {{
ErrBackupLocalDelete: 'Нельзя удалить локальные бэкапы'
ErrBackupLocalCreate: 'Нельзя создать локальные бэкапы'
ErrAlertConfigInUse: 'Конфигурация оповещений используется и не может быть удалена'
ErrAlertConfigPhoneExist: 'Номер телефона уже существует'
ErrPortInUsed: '{{ .detail }} порт уже занят'
ErrAppLimit: 'Количество установленных приложений превысило лимит'
ErrNotInstall: 'Приложение не установлено'
+1
View File
@@ -79,6 +79,7 @@ ErrBackupCheck: 'Bağlantı testi başarısız: {{ .err }}'
ErrBackupLocalDelete: 'Yerel yedek silme yok'
ErrBackupLocalCreate: 'Yerel yedek oluşturma yok'
ErrAlertConfigInUse: 'Uyarı yapılandırması kullanımda ve silinemez'
ErrAlertConfigPhoneExist: 'Telefon numarasi zaten mevcut'
ErrPortInUsed: '{{ .detail }} portu zaten kullanılıyor'
ErrAppLimit: 'Yüklenen uygulama sayısı sınırı aştı'
ErrNotInstall: 'Uygulama yüklenmedi'
+1
View File
@@ -79,6 +79,7 @@ ErrBackupCheck: '備份帳號測試連線失敗{{ .err }}'
ErrBackupLocalDelete: '暫時不支援刪除本機伺服器備份帳號'
ErrBackupLocalCreate: '暫時不支援建立本機伺服器備份帳號'
ErrAlertConfigInUse: '告警配置正在使用中,無法刪除'
ErrAlertConfigPhoneExist: '手機號碼已存在'
ErrPortInUsed: '{{ .detail }} 連接埠已被佔用!'
ErrAppLimit: '應用程式超出安裝數量限制'
ErrNotInstall: '應用程式未安裝'
+1
View File
@@ -78,6 +78,7 @@ ErrBackupCheck: "备份账号测试连接失败 {{ .err }}"
ErrBackupLocalDelete: "暂不支持删除本地服务器备份账号"
ErrBackupLocalCreate: "暂不支持创建本地服务器备份账号"
ErrAlertConfigInUse: "告警配置正在使用中,无法删除"
ErrAlertConfigPhoneExist: "手机号已存在"
#app
ErrPortInUsed: "{{ .detail }} 端口已被占用!"
+1 -1
View File
@@ -9,7 +9,7 @@ const alertConfigHiddenTypes = ['sms'];
const resolveAlertConfigExcludeTypes = (excludeTypes: string[] = []) => {
const globalStore = GlobalStore();
const types = new Set(excludeTypes);
if (globalStore.isIntl || globalStore.isEE) {
if (globalStore.isIntl || globalStore.isEE || !globalStore.isProductPro) {
alertConfigHiddenTypes.forEach((type) => types.add(type));
}
return Array.from(types);
@@ -275,15 +275,15 @@
class="flex items-center flex-row md:flex-nowrap sm:flex-nowrap flex-wrap justify-between gap-2 w-full"
>
<el-form-item prop="cycle" class="md:flex-1 sm:flex-1">
<el-input v-model.number="dialogData.rowData!.cycle" :max="200" width="200px">
<template #append>{{ $t('commons.units.minute') }}11</template>
<el-input v-model.number="dialogData.rowData!.cycle" :max="200">
<template #append>{{ $t('commons.units.minute') }}</template>
</el-input>
</el-form-item>
<span class="whitespace-nowrap input-help w-[4.5rem]">
<span class="whitespace-nowrap input-help !w-[5rem]">
{{ $t('xpack.alert.loginFail') }}
</span>
<el-form-item prop="count" class="md:flex-1 sm:flex-1 w-auto">
<el-form-item prop="count" class="md:flex-1 sm:flex-1">
<el-input v-model.number="dialogData.rowData!.count">
<template #append>{{ $t('commons.units.time') }}</template>
</el-input>
@@ -398,7 +398,7 @@ import { routerToName } from '@/utils/router';
import { checkCidr, checkCidrV6, checkIpV4V6 } from '@/utils/validate';
import { useGlobalStore } from '@/composables/useGlobalStore';
const { isMaster, isProductPro, isEE } = useGlobalStore();
const { isMaster, isProductPro, isEE, isIntl } = useGlobalStore();
const alertConfigs = ref<Alert.AlertConfigInfo[]>([]);
const loadAlertConfigs = async () => {
@@ -423,7 +423,7 @@ const configOptions = computed(() => {
type: c.type,
disabled:
c.status !== 'Enable' ||
(!isProductPro.value && ['weCom', 'dingTalk', 'feiShu', 'sms'].includes(c.type)),
((isIntl.value || !isProductPro.value) && ['weCom', 'dingTalk', 'feiShu', 'sms'].includes(c.type)),
}))
.sort((a, b) => Number(a.disabled) - Number(b.disabled));
});
@@ -156,7 +156,7 @@ const emailRules = {
const smsRules = {
smsDisplayName: [Rules.requiredInput, { validator: checkSmsDisplayNameDuplicate, trigger: 'blur' }],
smsPhone: [Rules.requiredInput, Rules.phone, { validator: checkPhoneDuplicate, trigger: 'blur' }],
smsPhone: [Rules.phone, { validator: checkPhoneDuplicate, trigger: 'blur' }],
smsDailyAlertNum: [Rules.integerNumber, checkNumberRange(20, 100)],
};
@@ -302,10 +302,6 @@ function checkSmsDisplayNameDuplicate(_rule: unknown, value: string, callback: (
function checkPhoneDuplicate(_rule: unknown, value: string, callback: (error?: Error) => void) {
const currentValue = normalizeDisplayName(value);
if (!currentValue) {
callback();
return;
}
const duplicated = alertConfigs.value.some((item) => {
if (item.type !== 'sms') {
@@ -151,7 +151,7 @@ import { MsgSuccess } from '@/utils/message';
import AlertDrawer from '@/views/setting/alert/setting/drawer/index.vue';
import { Alert } from '@/api/interface/alert';
const { docsUrl, isMaster, isMobile, isProductPro, isEE } = useGlobalStore();
const { docsUrl, isMaster, isMobile, isProductPro, isEE, isIntl } = useGlobalStore();
const loading = ref(false);
const alertDrawerRef = ref();
@@ -483,7 +483,7 @@ const buttons = computed(() => [
openEditDrawer(row);
},
disabled: (row: Alert.AlertConfigInfo) =>
!isProductPro.value && ['weCom', 'dingTalk', 'feiShu', 'sms'].includes(row.type),
(isIntl.value || !isProductPro.value) && ['weCom', 'dingTalk', 'feiShu', 'sms'].includes(row.type),
},
{
label: i18n.global.t('commons.button.delete'),