Browse Source

feat: support notify rule (#2500)

Co-authored-by: flashbo <36443248+lwb0214@users.noreply.github.com>
Co-authored-by: Xu Bin <140785332+Reditiny@users.noreply.github.com>
main
Yening Qin GitHub 1 year ago
parent
commit
7fd415d7f7
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
46 changed files with 5031 additions and 122 deletions
  1. +8
    -3
      alert/alert.go
  2. +244
    -8
      alert/dispatch/dispatch.go
  3. +1
    -0
      alert/process/process.go
  4. +3
    -3
      alert/sender/callback.go
  5. +1
    -1
      alert/sender/email.go
  6. +1
    -1
      alert/sender/mm.go
  7. +1
    -1
      alert/sender/plugin.go
  8. +1
    -1
      alert/sender/telegram.go
  9. +2
    -2
      alert/sender/webhook.go
  10. +36
    -0
      center/cconf/ops.go
  11. +4
    -1
      center/center.go
  12. +26
    -0
      center/router/router.go
  13. +10
    -1
      center/router/router_alert_rule.go
  14. +4
    -0
      center/router/router_funcs.go
  15. +188
    -0
      center/router/router_message_template.go
  16. +15
    -16
      center/router/router_notification_record.go
  17. +243
    -0
      center/router/router_notify_channel.go
  18. +17
    -0
      center/router/router_notify_channel_test.go
  19. +11
    -0
      center/router/router_notify_config.go
  20. +273
    -0
      center/router/router_notify_rule.go
  21. +15
    -5
      center/router/router_notify_tpl.go
  22. +4
    -1
      cmd/edge/edge.go
  23. +1
    -1
      front/statik/statik.go
  24. +1
    -0
      go.mod
  25. +2
    -0
      go.sum
  26. +139
    -0
      memsto/message_template_cache.go
  27. +334
    -0
      memsto/notify_channel_cache.go
  28. +139
    -0
      memsto/notify_rule_cache.go
  29. +61
    -1
      models/alert_cur_event.go
  30. +56
    -0
      models/alert_his_event.go
  31. +32
    -0
      models/alert_mute.go
  32. +24
    -0
      models/alert_rule.go
  33. +2
    -0
      models/alert_subscribe.go
  34. +628
    -0
      models/message_tpl.go
  35. +65
    -7
      models/migrate/migrate.go
  36. +16
    -14
      models/notification_record.go
  37. +1299
    -0
      models/notify_channel.go
  38. +728
    -0
      models/notify_channel_test.go
  39. +241
    -0
      models/notify_rule.go
  40. +0
    -51
      models/notify_tpl.go
  41. +52
    -4
      pkg/i18nx/var.go
  42. +15
    -0
      pkg/slice/contains.go
  43. +8
    -0
      pkg/str/verify.go
  44. +48
    -0
      pkg/tplx/fns.go
  45. +25
    -0
      pkg/tplx/tpl_test.go
  46. +7
    -0
      pkg/tplx/tplx.go

+ 8
- 3
alert/alert.go View File

@@ -64,6 +64,9 @@ func Initialize(configDir string, cryptoKey string) (func(), error) {
userGroupCache := memsto.NewUserGroupCache(ctx, syncStats)
taskTplsCache := memsto.NewTaskTplCache(ctx)
configCvalCache := memsto.NewCvalCache(ctx, syncStats)
notifyRuleCache := memsto.NewNotifyRuleCache(ctx, syncStats)
notifyChannelCache := memsto.NewNotifyChannelCache(ctx, syncStats)
messageTemplateCache := memsto.NewMessageTemplateCache(ctx, syncStats)

promClients := prom.NewPromClient(ctx)
dispatch.InitRegisterQueryFunc(promClients)
@@ -72,7 +75,7 @@ func Initialize(configDir string, cryptoKey string) (func(), error) {

macros.RegisterMacro(macros.MacroInVain)
dscache.Init(ctx, false)
Start(config.Alert, config.Pushgw, syncStats, alertStats, externalProcessors, targetCache, busiGroupCache, alertMuteCache, alertRuleCache, notifyConfigCache, taskTplsCache, dsCache, ctx, promClients, userCache, userGroupCache)
Start(config.Alert, config.Pushgw, syncStats, alertStats, externalProcessors, targetCache, busiGroupCache, alertMuteCache, alertRuleCache, notifyConfigCache, taskTplsCache, dsCache, ctx, promClients, userCache, userGroupCache, notifyRuleCache, notifyChannelCache, messageTemplateCache)

r := httpx.GinEngine(config.Global.RunMode, config.HTTP,
configCvalCache.PrintBodyPaths, configCvalCache.PrintAccessLog)
@@ -95,12 +98,14 @@ func Initialize(configDir string, cryptoKey string) (func(), error) {

func Start(alertc aconf.Alert, pushgwc pconf.Pushgw, syncStats *memsto.Stats, alertStats *astats.Stats, externalProcessors *process.ExternalProcessorsType, targetCache *memsto.TargetCacheType, busiGroupCache *memsto.BusiGroupCacheType,
alertMuteCache *memsto.AlertMuteCacheType, alertRuleCache *memsto.AlertRuleCacheType, notifyConfigCache *memsto.NotifyConfigCacheType, taskTplsCache *memsto.TaskTplCache, datasourceCache *memsto.DatasourceCacheType, ctx *ctx.Context,
promClients *prom.PromClientMap, userCache *memsto.UserCacheType, userGroupCache *memsto.UserGroupCacheType) {
promClients *prom.PromClientMap, userCache *memsto.UserCacheType, userGroupCache *memsto.UserGroupCacheType, notifyRuleCache *memsto.NotifyRuleCacheType, notifyChannelCache *memsto.NotifyChannelCacheType, messageTemplateCache *memsto.MessageTemplateCacheType) {
alertSubscribeCache := memsto.NewAlertSubscribeCache(ctx, syncStats)
recordingRuleCache := memsto.NewRecordingRuleCache(ctx, syncStats)
targetsOfAlertRulesCache := memsto.NewTargetOfAlertRuleCache(ctx, alertc.Heartbeat.EngineName, syncStats)

go models.InitNotifyConfig(ctx, alertc.Alerting.TemplatesDir)
go models.InitNotifyChannel(ctx)
go models.InitMessageTemplate(ctx)

naming := naming.NewNaming(ctx, alertc.Heartbeat, alertStats)

@@ -110,7 +115,7 @@ func Start(alertc aconf.Alert, pushgwc pconf.Pushgw, syncStats *memsto.Stats, al
eval.NewScheduler(alertc, externalProcessors, alertRuleCache, targetCache, targetsOfAlertRulesCache,
busiGroupCache, alertMuteCache, datasourceCache, promClients, naming, ctx, alertStats)

dp := dispatch.NewDispatch(alertRuleCache, userCache, userGroupCache, alertSubscribeCache, targetCache, notifyConfigCache, taskTplsCache, alertc.Alerting, ctx, alertStats)
dp := dispatch.NewDispatch(alertRuleCache, userCache, userGroupCache, alertSubscribeCache, targetCache, notifyConfigCache, taskTplsCache, notifyRuleCache, notifyChannelCache, messageTemplateCache, alertc.Alerting, ctx, alertStats)
consumer := dispatch.NewConsumer(alertc.Alerting, ctx, dp, promClients)

notifyRecordComsumer := sender.NewNotifyRecordConsumer(ctx)


+ 244
- 8
alert/dispatch/dispatch.go View File

@@ -30,6 +30,10 @@ type Dispatch struct {
notifyConfigCache *memsto.NotifyConfigCacheType
taskTplsCache *memsto.TaskTplCache

notifyRuleCache *memsto.NotifyRuleCacheType
notifyChannelCache *memsto.NotifyChannelCacheType
messageTemplateCache *memsto.MessageTemplateCacheType

alerting aconf.Alerting

Senders map[string]sender.Sender
@@ -47,15 +51,19 @@ type Dispatch struct {
// 创建一个 Notify 实例
func NewDispatch(alertRuleCache *memsto.AlertRuleCacheType, userCache *memsto.UserCacheType, userGroupCache *memsto.UserGroupCacheType,
alertSubscribeCache *memsto.AlertSubscribeCacheType, targetCache *memsto.TargetCacheType, notifyConfigCache *memsto.NotifyConfigCacheType,
taskTplsCache *memsto.TaskTplCache, alerting aconf.Alerting, ctx *ctx.Context, astats *astats.Stats) *Dispatch {
taskTplsCache *memsto.TaskTplCache, notifyRuleCache *memsto.NotifyRuleCacheType, notifyChannelCache *memsto.NotifyChannelCacheType,
messageTemplateCache *memsto.MessageTemplateCacheType, alerting aconf.Alerting, ctx *ctx.Context, astats *astats.Stats) *Dispatch {
notify := &Dispatch{
alertRuleCache: alertRuleCache,
userCache: userCache,
userGroupCache: userGroupCache,
alertSubscribeCache: alertSubscribeCache,
targetCache: targetCache,
notifyConfigCache: notifyConfigCache,
taskTplsCache: taskTplsCache,
alertRuleCache: alertRuleCache,
userCache: userCache,
userGroupCache: userGroupCache,
alertSubscribeCache: alertSubscribeCache,
targetCache: targetCache,
notifyConfigCache: notifyConfigCache,
taskTplsCache: taskTplsCache,
notifyRuleCache: notifyRuleCache,
notifyChannelCache: notifyChannelCache,
messageTemplateCache: messageTemplateCache,

alerting: alerting,

@@ -131,6 +139,233 @@ func (e *Dispatch) relaodTpls() error {
return nil
}

func (e *Dispatch) HandleEventNotifyV2(event *models.AlertCurEvent, isSubscribe bool) {

if len(event.NotifyRuleIDs) > 0 {
for _, notifyRuleId := range event.NotifyRuleIDs {
logger.Infof("notify rule ids: %v, event: %+v", notifyRuleId, event)
notifyRule := e.notifyRuleCache.Get(notifyRuleId)
if notifyRule == nil {
continue
}

for i := range notifyRule.NotifyConfigs {
if !NotifyRuleApplicable(&notifyRule.NotifyConfigs[i], event) {
continue
}
notifyChannel := e.notifyChannelCache.Get(notifyRule.NotifyConfigs[i].ChannelID)
messageTemplate := e.messageTemplateCache.Get(notifyRule.NotifyConfigs[i].TemplateID)
if notifyChannel == nil {
logger.Warningf("notify_id: %d, event:%+v, channel_id:%d, template_id: %d, notify_channel not found", notifyRuleId, event, notifyRule.NotifyConfigs[i].ChannelID, notifyRule.NotifyConfigs[i].TemplateID)
continue
}

if notifyChannel.RequestType != "flashduty" && messageTemplate == nil {
logger.Warningf("notify_id: %d, channel_name: %v, event:%+v, template_id: %d, message_template not found", notifyRuleId, notifyChannel.Ident, event, notifyRule.NotifyConfigs[i].TemplateID)
continue
}

// todo go send
// todo 聚合 event
go e.sendV2([]*models.AlertCurEvent{event}, notifyRuleId, &notifyRule.NotifyConfigs[i], notifyChannel, messageTemplate)
}
}
}
}

func NotifyRuleApplicable(notifyConfig *models.NotifyConfig, event *models.AlertCurEvent) bool {
tm := time.Unix(event.TriggerTime, 0)
triggerTime := tm.Format("15:04")
triggerWeek := int(tm.Weekday())

timeMatch := false

if len(notifyConfig.TimeRanges) == 0 {
timeMatch = true
}
for j := range notifyConfig.TimeRanges {
if timeMatch {
break
}
enableStime := notifyConfig.TimeRanges[j].Start
enableEtime := notifyConfig.TimeRanges[j].End
enableDaysOfWeek := notifyConfig.TimeRanges[j].Week
length := len(enableDaysOfWeek)
// enableStime,enableEtime,enableDaysOfWeek三者长度肯定相同,这里循环一个即可
for i := 0; i < length; i++ {
if enableDaysOfWeek[i] != triggerWeek {
continue
}

if enableStime < enableEtime {
if enableEtime == "23:59" {
// 02:00-23:59,这种情况做个特殊处理,相当于左闭右闭区间了
if triggerTime < enableStime {
// mute, 即没生效
continue
}
} else {
// 02:00-04:00 或者 02:00-24:00
if triggerTime < enableStime || triggerTime >= enableEtime {
// mute, 即没生效
continue
}
}
} else if enableStime > enableEtime {
// 21:00-09:00
if triggerTime < enableStime && triggerTime >= enableEtime {
// mute, 即没生效
continue
}
}

// 到这里说明当前时刻在告警规则的某组生效时间范围内,即没有 mute,直接返回 false
timeMatch = true
break
}
}

severityMatch := false
for i := range notifyConfig.Severities {
if notifyConfig.Severities[i] == event.Severity {
severityMatch = true
}
}

tagMatch := true
if len(notifyConfig.LabelKeys) > 0 {
tagFilters, err := models.ParseTagFilter(notifyConfig.LabelKeys)
if err != nil {
logger.Errorf("failed to parse tag filter: %v", err)
return false
}
tagMatch = common.MatchTags(event.TagsMap, tagFilters)
}

attributesMatch := true
if len(notifyConfig.Attributes) > 0 {
tagFilters, err := models.ParseTagFilter(notifyConfig.Attributes)
if err != nil {
logger.Errorf("failed to parse tag filter: %v", err)
return false
}

attributesMatch = common.MatchTags(event.JsonTagsAndValue(), tagFilters)
}

return timeMatch && severityMatch && tagMatch && attributesMatch
}

func GetNotifyConfigParams(notifyConfig *models.NotifyConfig, userCache *memsto.UserCacheType, userGroupCache *memsto.UserGroupCacheType) ([]*models.User, []int64, map[string]string) {
customParams := make(map[string]string)
var userInfos []*models.User
var flashDutyChannelIDs []int64
var userInfoParams models.CustomParams

for key, value := range notifyConfig.Params {
switch key {
case "user_ids", "user_group_ids", "ids":
if data, err := json.Marshal(value); err == nil {
var ids []int64
if json.Unmarshal(data, &ids) == nil {
if key == "user_ids" {
userInfoParams.UserIDs = ids
} else if key == "user_group_ids" {
userInfoParams.UserGroupIDs = ids
} else if key == "ids" {
flashDutyChannelIDs = ids
}
}
}
default:
customParams[key] = value.(string)
}
}

users := userCache.GetByUserIds(userInfoParams.UserIDs)
visited := make(map[int64]bool)
for _, user := range users {
if visited[user.Id] {
continue
}
visited[user.Id] = true
userInfos = append(userInfos, user)
}
userGroups := userGroupCache.GetByUserGroupIds(userInfoParams.UserGroupIDs)
for _, userGroup := range userGroups {
for _, user := range userGroup.Users {
if visited[user.Id] {
continue
}
visited[user.Id] = true
userInfos = append(userInfos, &user)
}
}

return userInfos, flashDutyChannelIDs, customParams
}

func (e *Dispatch) sendV2(events []*models.AlertCurEvent, notifyRuleId int64, notifyConfig *models.NotifyConfig, notifyChannel *models.NotifyChannelConfig, messageTemplate *models.MessageTemplate) {
if len(events) == 0 {
logger.Errorf("notify_id: %d events is empty", notifyRuleId)
return
}

tplContent := messageTemplate.RenderEvent(events)

userInfos, flashDutyChannelIDs, customParams := GetNotifyConfigParams(notifyConfig, e.userCache, e.userGroupCache)

e.Astats.GaugeNotifyRecordQueueSize.Inc()
defer e.Astats.GaugeNotifyRecordQueueSize.Dec()

switch notifyChannel.RequestType {
case "flashduty":
for i := range flashDutyChannelIDs {
respBody, err := notifyChannel.SendFlashDuty(events, flashDutyChannelIDs[i], e.notifyChannelCache.GetHttpClient(notifyChannel.ID))
logger.Infof("notify_id: %d, channel_name: %v, event:%+v, IntegrationUrl: %v, respBody: %v, err: %v", notifyRuleId, notifyChannel.Name, events[0], notifyChannel.RequestConfig.FlashDutyRequestConfig.IntegrationUrl, respBody, err)
sender.NotifyRecord(e.ctx, events, notifyRuleId, notifyChannel.Name, notifyChannel.RequestConfig.FlashDutyRequestConfig.IntegrationUrl, respBody, err)
}
return
case "http":
if e.notifyChannelCache.HttpConcurrencyAdd(notifyChannel.ID) {
defer e.notifyChannelCache.HttpConcurrencyDone(notifyChannel.ID)
}

if notifyChannel.ParamConfig.UserInfo != nil && len(userInfos) > 0 {
for i := range userInfos {
respBody, err := notifyChannel.SendHTTP(events, tplContent, customParams, userInfos[i], e.notifyChannelCache.GetHttpClient(notifyChannel.ID))
logger.Infof("notify_id: %d, channel_name: %v, event:%+v, tplContent:%s, customParams:%v, userInfo:%+v, respBody: %v, err: %v", notifyRuleId, notifyChannel.Name, events[0], tplContent, customParams, userInfos[i], respBody, err)
sender.NotifyRecord(e.ctx, events, notifyRuleId, notifyChannel.Name, notifyChannel.RequestConfig.HTTPRequestConfig.URL, respBody, err)
}
} else {
respBody, err := notifyChannel.SendHTTP(events, tplContent, customParams, nil, e.notifyChannelCache.GetHttpClient(notifyChannel.ID))
logger.Infof("notify_id: %d, channel_name: %v, event:%+v, tplContent:%s, customParams:%v, respBody: %v, err: %v", notifyRuleId, notifyChannel.Name, events[0], tplContent, customParams, respBody, err)
sender.NotifyRecord(e.ctx, events, notifyRuleId, notifyChannel.Name, notifyChannel.RequestConfig.HTTPRequestConfig.URL, respBody, err)
}

case "email":
err := notifyChannel.SendEmail(events, tplContent, userInfos, e.notifyChannelCache.GetSmtpClient(notifyChannel.ID))
if err != nil {
logger.Errorf("send email error: %v", err)
}
for i := range userInfos {
msg := ""
if err == nil {
msg = "ok"
}

// todo 这里的通知记录需要调整
sender.NotifyRecord(e.ctx, events, notifyRuleId, notifyChannel.Name, userInfos[i].Email, msg, err)
}
case "script":
target, res, err := notifyChannel.SendScript(events, tplContent, customParams, userInfos)
logger.Infof("notify_id: %d, channel_name: %v, event:%+v, tplContent:%s, customParams:%v, target:%s, res:%s, err:%v", notifyRuleId, notifyChannel.Name, events[0], tplContent, customParams, target, res, err)
sender.NotifyRecord(e.ctx, events, notifyRuleId, notifyChannel.Name, target, res, err)
default:
logger.Warningf("notify_id: %d, channel_name: %v, event:%+v send type not found", notifyRuleId, notifyChannel.Name, events[0])
}
}

// HandleEventNotify 处理event事件的主逻辑
// event: 告警/恢复事件
// isSubscribe: 告警事件是否由subscribe的配置产生
@@ -173,6 +408,7 @@ func (e *Dispatch) HandleEventNotify(event *models.AlertCurEvent, isSubscribe bo
}

// 处理事件发送,这里用一个goroutine处理一个event的所有发送事件
go e.HandleEventNotifyV2(event, isSubscribe)
go e.Send(rule, event, notifyTarget, isSubscribe)

// 如果是不是订阅规则出现的event, 则需要处理订阅规则的event


+ 1
- 0
alert/process/process.go View File

@@ -154,6 +154,7 @@ func (p *Processor) Handle(anomalyPoints []models.AnomalyPoint, from string, inh
eventsMap := make(map[string][]*models.AlertCurEvent)
for _, anomalyPoint := range anomalyPoints {
event := p.BuildEvent(anomalyPoint, from, now, ruleHash)
event.NotifyRuleIDs = cachedRule.NotifyRuleIds
// 如果 event 被 mute 了,本质也是 fire 的状态,这里无论如何都添加到 alertingKeys 中,防止 fire 的事件自动恢复了
hash := event.Hash
alertingKeys[hash] = struct{}{}


+ 3
- 3
alert/sender/callback.go View File

@@ -135,14 +135,14 @@ func (c *DefaultCallBacker) CallBack(ctx CallBackContext) {
func doSendAndRecord(ctx *ctx.Context, url, token string, body interface{}, channel string,
stats *astats.Stats, events []*models.AlertCurEvent) {
res, err := doSend(url, body, channel, stats)
NotifyRecord(ctx, events, channel, token, res, err)
NotifyRecord(ctx, events, 0, channel, token, res, err)
}

func NotifyRecord(ctx *ctx.Context, evts []*models.AlertCurEvent, channel, target, res string, err error) {
func NotifyRecord(ctx *ctx.Context, evts []*models.AlertCurEvent, notifyRuleID int64, channel, target, res string, err error) {
// 一个通知可能对应多个 event,都需要记录
notis := make([]*models.NotificaitonRecord, 0, len(evts))
for _, evt := range evts {
noti := models.NewNotificationRecord(evt, channel, target)
noti := models.NewNotificationRecord(evt, notifyRuleID, channel, target)
if err != nil {
noti.SetStatus(models.NotiStatusFailure)
noti.SetDetails(err.Error())


+ 1
- 1
alert/sender/email.go View File

@@ -205,7 +205,7 @@ func startEmailSender(ctx *ctx.Context, smtp aconf.SMTPConfig) {
if err == nil {
msg = "ok"
}
NotifyRecord(ctx, m.events, models.Email, to, msg, err)
NotifyRecord(ctx, m.events, 0, models.Email, to, msg, err)
}

size++


+ 1
- 1
alert/sender/mm.go View File

@@ -74,7 +74,7 @@ func SendMM(ctx *ctx.Context, message MatterMostMessage, events []*models.AlertC
u, err := url.Parse(message.Tokens[i])
if err != nil {
logger.Errorf("mm_sender: failed to parse error=%v", err)
NotifyRecord(ctx, events, channel, message.Tokens[i], "", err)
NotifyRecord(ctx, events, 0, channel, message.Tokens[i], "", err)
continue
}



+ 1
- 1
alert/sender/plugin.go View File

@@ -104,7 +104,7 @@ func alertingCallScript(ctx *ctx.Context, stdinBytes []byte, notifyScript models
res = res[:validLen] + "..."
}

NotifyRecord(ctx, []*models.AlertCurEvent{event}, channel, cmd.String(), res, buildErr(err, isTimeout))
NotifyRecord(ctx, []*models.AlertCurEvent{event}, 0, channel, cmd.String(), res, buildErr(err, isTimeout))

if isTimeout {
if err == nil {


+ 1
- 1
alert/sender/telegram.go View File

@@ -72,7 +72,7 @@ func SendTelegram(ctx *ctx.Context, message TelegramMessage, events []*models.Al
for i := 0; i < len(message.Tokens); i++ {
if !strings.Contains(message.Tokens[i], "/") && !strings.HasPrefix(message.Tokens[i], "https://") {
logger.Errorf("telegram_sender: result=fail invalid token=%s", message.Tokens[i])
NotifyRecord(ctx, events, channel, message.Tokens[i], "", errors.New("invalid token"))
NotifyRecord(ctx, events, 0, channel, message.Tokens[i], "", errors.New("invalid token"))
continue
}
var url string


+ 2
- 2
alert/sender/webhook.go View File

@@ -100,7 +100,7 @@ func SingleSendWebhooks(ctx *ctx.Context, webhooks map[string]*models.Webhook, e
retryCount := 0
for retryCount < 3 {
needRetry, res, err := sendWebhook(conf, event, stats)
NotifyRecord(ctx, []*models.AlertCurEvent{event}, "webhook", conf.Url, res, err)
NotifyRecord(ctx, []*models.AlertCurEvent{event}, 0, "webhook", conf.Url, res, err)
if !needRetry {
break
}
@@ -170,7 +170,7 @@ func StartConsumer(ctx *ctx.Context, queue *WebhookQueue, popSize int, webhook *
retryCount := 0
for retryCount < webhook.RetryCount {
needRetry, res, err := sendWebhook(webhook, events, stats)
go NotifyRecord(ctx, events, "webhook", webhook.Url, res, err)
go NotifyRecord(ctx, events, 0, "webhook", webhook.Url, res, err)
if !needRetry {
break
}


+ 36
- 0
center/cconf/ops.go View File

@@ -292,5 +292,41 @@ ops:
cname: View SSO Configuration
- name: "/site-settings"
cname: View Site Settings

- name: message-templates
cname: Message Templates
ops:
- name: "/notification-templates"
cname: View Message Templates
- name: "/notification-templates/add"
cname: Add Message Templates
- name: "/notification-templates/put"
cname: Modify Message Templates
- name: "/notification-templates/del"
cname: Delete Message Templates

- name: notify-rules
cname: Notify Rules
ops:
- name: "/notification-rules"
cname: View Notify Rules
- name: "/notification-rules/add"
cname: Add Notify Rules
- name: "/notification-rules/put"
cname: Modify Notify Rules
- name: "/notification-rules/del"
cname: Delete Notify Rules

- name: notify-channels
cname: Notify Channels
ops:
- name: "/notification-channels"
cname: View Notify Channels
- name: "/notification-channels/add"
cname: Add Notify Channels
- name: "/notification-channels/put"
cname: Modify Notify Channels
- name: "/notification-channels/del"
cname: Delete Notify Channels
`
)

+ 4
- 1
center/center.go View File

@@ -102,6 +102,9 @@ func Initialize(configDir string, cryptoKey string) (func(), error) {
userGroupCache := memsto.NewUserGroupCache(ctx, syncStats)
taskTplCache := memsto.NewTaskTplCache(ctx)
configCvalCache := memsto.NewCvalCache(ctx, syncStats)
notifyRuleCache := memsto.NewNotifyRuleCache(ctx, syncStats)
notifyChannelCache := memsto.NewNotifyChannelCache(ctx, syncStats)
messageTemplateCache := memsto.NewMessageTemplateCache(ctx, syncStats)
userTokenCache := memsto.NewUserTokenCache(ctx, syncStats)

sso := sso.Init(config.Center, ctx, configCache)
@@ -113,7 +116,7 @@ func Initialize(configDir string, cryptoKey string) (func(), error) {

macros.RegisterMacro(macros.MacroInVain)
dscache.Init(ctx, false)
alert.Start(config.Alert, config.Pushgw, syncStats, alertStats, externalProcessors, targetCache, busiGroupCache, alertMuteCache, alertRuleCache, notifyConfigCache, taskTplCache, dsCache, ctx, promClients, userCache, userGroupCache)
alert.Start(config.Alert, config.Pushgw, syncStats, alertStats, externalProcessors, targetCache, busiGroupCache, alertMuteCache, alertRuleCache, notifyConfigCache, taskTplCache, dsCache, ctx, promClients, userCache, userGroupCache, notifyRuleCache, notifyChannelCache, messageTemplateCache)

writers := writer.NewWriters(config.Pushgw)



+ 26
- 0
center/router/router.go View File

@@ -458,6 +458,7 @@ func (rt *Router) Config(r *gin.Engine) {
pages.POST("/notify-tpl", rt.auth(), rt.user(), rt.notifyTplAdd)
pages.DELETE("/notify-tpl/:id", rt.auth(), rt.user(), rt.notifyTplDel)
pages.POST("/notify-tpl/preview", rt.auth(), rt.user(), rt.notifyTplPreview)
pages.GET("/message-templates-v2", rt.auth(), rt.user(), rt.messageTemplateGets)

pages.GET("/sso-configs", rt.auth(), rt.admin(), rt.ssoConfigGets)
pages.PUT("/sso-config", rt.auth(), rt.admin(), rt.ssoConfigUpdate)
@@ -478,6 +479,8 @@ func (rt *Router) Config(r *gin.Engine) {
pages.PUT("/notify-config", rt.auth(), rt.admin(), rt.notifyConfigPut)
pages.PUT("/smtp-config-test", rt.auth(), rt.admin(), rt.attemptSendEmail)

pages.GET("/notify-channels-v2", rt.auth(), rt.user(), rt.notifyChannelConfigGets)

pages.GET("/es-index-pattern", rt.auth(), rt.esIndexPatternGet)
pages.GET("/es-index-pattern-list", rt.auth(), rt.esIndexPatternGetList)
pages.POST("/es-index-pattern", rt.auth(), rt.admin(), rt.esIndexPatternAdd)
@@ -512,6 +515,29 @@ func (rt *Router) Config(r *gin.Engine) {
pages.DELETE("/builtin-payloads", rt.auth(), rt.user(), rt.perm("/built-in-components/del"), rt.builtinPayloadsDel)
pages.GET("/builtin-payload", rt.auth(), rt.user(), rt.builtinPayloadsGetByUUIDOrID)

pages.POST("/message-templates", rt.auth(), rt.user(), rt.perm("/notification-templates/add"), rt.messageTemplatesAdd)
pages.DELETE("/message-templates", rt.auth(), rt.user(), rt.perm("/notification-templates/del"), rt.messageTemplatesDel)
pages.PUT("/message-template/:id", rt.auth(), rt.user(), rt.perm("/notification-templates/put"), rt.messageTemplatePut)
pages.GET("/message-template/:id", rt.auth(), rt.user(), rt.perm("/notification-templates"), rt.messageTemplateGet)
pages.GET("/message-templates", rt.auth(), rt.user(), rt.messageTemplatesGet)
pages.POST("/events-message", rt.auth(), rt.user(), rt.eventsMessage)

pages.POST("/notify-rules", rt.auth(), rt.user(), rt.perm("/notification-rules/add"), rt.notifyRulesAdd)
pages.DELETE("/notify-rules", rt.auth(), rt.user(), rt.perm("/notification-rules/del"), rt.notifyRulesDel)
pages.PUT("/notify-rule/:id", rt.auth(), rt.user(), rt.perm("/notification-rules/put"), rt.notifyRulePut)
pages.GET("/notify-rule/:id", rt.auth(), rt.user(), rt.perm("/notification-rules"), rt.notifyRuleGet)
pages.GET("/notify-rules", rt.auth(), rt.user(), rt.perm("/notification-rules"), rt.notifyRulesGet)
pages.POST("/notify-rule/test", rt.auth(), rt.user(), rt.perm("/notification-rules"), rt.notifyTest)
pages.GET("/notify-rule/custom-params", rt.auth(), rt.user(), rt.perm("/notification-rules"), rt.notifyRuleCustomParamsGet)

pages.POST("/notify-channel-configs", rt.auth(), rt.user(), rt.perm("/notification-channels/add"), rt.notifyChannelsAdd)
pages.DELETE("/notify-channel-configs", rt.auth(), rt.user(), rt.perm("/notification-channels/del"), rt.notifyChannelsDel)
pages.PUT("/notify-channel-config/:id", rt.auth(), rt.user(), rt.perm("/notification-channels/put"), rt.notifyChannelPut)
pages.GET("/notify-channel-config/:id", rt.auth(), rt.user(), rt.perm("/notification-channels"), rt.notifyChannelGet)
pages.GET("/notify-channel-configs", rt.auth(), rt.user(), rt.perm("/notification-channels"), rt.notifyChannelsGet)
pages.GET("/simplified-notify-channel-configs", rt.notifyChannelsGetForNormalUser)
pages.GET("/flashduty-channel-list/:id", rt.auth(), rt.user(), rt.flashDutyNotifyChannelsGet)
pages.GET("/notify-channel-config", rt.auth(), rt.user(), rt.perm("/notification-channels"), rt.notifyChannelGetBy)
}

r.GET("/api/n9e/versions", func(c *gin.Context) {


+ 10
- 1
center/router/router_alert_rule.go View File

@@ -428,7 +428,16 @@ func (rt *Router) alertRulePutFields(c *gin.Context) {
}

for k, v := range f.Fields {
ginx.Dangerous(ar.UpdateColumn(rt.Ctx, k, v))
// 检查 v 是否为各种切片类型
switch v.(type) {
case []interface{}, []int64, []int, []string:
// 将切片转换为 JSON 字符串
bytes, err := json.Marshal(v)
ginx.Dangerous(err)
ginx.Dangerous(ar.UpdateColumn(rt.Ctx, k, string(bytes)))
default:
ginx.Dangerous(ar.UpdateColumn(rt.Ctx, k, v))
}
}
}



+ 4
- 0
center/router/router_funcs.go View File

@@ -49,6 +49,10 @@ func (rt *Router) statistic(c *gin.Context) {
statistics, err = models.ConfigCvalStatistics(rt.Ctx)
ginx.NewRender(c).Data(statistics, err)
return
case "message_template":
statistics, err = models.MessageTemplateStatistics(rt.Ctx)
ginx.NewRender(c).Data(statistics, err)
return
default:
ginx.Bomb(http.StatusBadRequest, "invalid name")
}


+ 188
- 0
center/router/router_message_template.go View File

@@ -0,0 +1,188 @@
package router

import (
"bytes"
"fmt"
"html/template"
"net/http"
"strings"
"time"

"github.com/ccfos/nightingale/v6/models"
"github.com/ccfos/nightingale/v6/pkg/slice"
"github.com/ccfos/nightingale/v6/pkg/tplx"
"github.com/gin-gonic/gin"
"github.com/toolkits/pkg/ginx"
"github.com/toolkits/pkg/str"
)

func (rt *Router) messageTemplatesAdd(c *gin.Context) {
var lst []*models.MessageTemplate
ginx.BindJSON(c, &lst)
if len(lst) == 0 {
ginx.Bomb(http.StatusBadRequest, "input json is empty")
}

me := c.MustGet("user").(*models.User)
isAdmin := me.IsAdmin()
idents := make([]string, 0, len(lst))
gids, err := models.MyGroupIds(rt.Ctx, me.Id)
ginx.Dangerous(err)
for _, tpl := range lst {
ginx.Dangerous(tpl.Verify())
if !isAdmin && !slice.HaveIntersection(gids, tpl.UserGroupIds) {
ginx.Bomb(http.StatusForbidden, "no permission")
}
idents = append(idents, tpl.Ident)

tpl.CreateBy = me.Username
tpl.CreateAt = time.Now().Unix()
tpl.UpdateBy = me.Username
tpl.UpdateAt = time.Now().Unix()
}
lstWithSameId, err := models.MessageTemplatesGet(rt.Ctx, "ident IN ?", idents)
ginx.Dangerous(err)
if len(lstWithSameId) > 0 {
ginx.Bomb(http.StatusBadRequest, "ident already exists")
}

ginx.Dangerous(models.DB(rt.Ctx).CreateInBatches(lst, 100).Error)
ids := make([]int64, 0, len(lst))
for _, tpl := range lst {
ids = append(ids, tpl.ID)
}
ginx.NewRender(c).Data(ids, nil)
}

func (rt *Router) messageTemplatesDel(c *gin.Context) {
var f idsForm
ginx.BindJSON(c, &f)
f.Verify()

lst, err := models.MessageTemplatesGet(rt.Ctx, "id in (?)", f.Ids)
ginx.Dangerous(err)
notifyRuleIds, err := models.UsedByNotifyRule(rt.Ctx, models.MsgTplList(lst))
ginx.Dangerous(err)
if len(notifyRuleIds) > 0 {
ginx.NewRender(c).Message(fmt.Errorf("used by notify rule: %v", notifyRuleIds))
return
}
if me := c.MustGet("user").(*models.User); !me.IsAdmin() {
gids, err := models.MyGroupIds(rt.Ctx, me.Id)
ginx.Dangerous(err)
for _, t := range lst {
if !slice.HaveIntersection[int64](gids, t.UserGroupIds) {
ginx.Bomb(http.StatusForbidden, "no permission")
}
}
}

ginx.NewRender(c).Message(models.DB(rt.Ctx).Delete(
&models.MessageTemplate{}, "id in (?)", f.Ids).Error)
}

func (rt *Router) messageTemplatePut(c *gin.Context) {
var f models.MessageTemplate
ginx.BindJSON(c, &f)

mt, err := models.MessageTemplateGet(rt.Ctx, "id = ?", ginx.UrlParamInt64(c, "id"))
ginx.Dangerous(err)
if mt == nil {
ginx.Bomb(http.StatusNotFound, "message template not found")
}

me := c.MustGet("user").(*models.User)
if !me.IsAdmin() {
gids, err := models.MyGroupIds(rt.Ctx, me.Id)
ginx.Dangerous(err)
if !slice.HaveIntersection[int64](gids, mt.UserGroupIds) {
ginx.Bomb(http.StatusForbidden, "no permission")
}
}

f.UpdateBy = me.Username
ginx.NewRender(c).Message(mt.Update(rt.Ctx, f))
}

func (rt *Router) messageTemplateGet(c *gin.Context) {
me := c.MustGet("user").(*models.User)
gids, err := models.MyGroupIds(rt.Ctx, me.Id)
ginx.Dangerous(err)

tid := ginx.UrlParamInt64(c, "id")
mt, err := models.MessageTemplateGet(rt.Ctx, "id = ?", tid)
ginx.Dangerous(err)
if mt == nil {
ginx.Bomb(http.StatusNotFound, "message template not found")
}
if mt.Private == 1 && !slice.HaveIntersection[int64](gids, mt.UserGroupIds) {
ginx.Bomb(http.StatusForbidden, "no permission")
}

ginx.NewRender(c).Data(mt, nil)
}

func (rt *Router) messageTemplatesGet(c *gin.Context) {
var notifyChannelIdents []string
if tmp := ginx.QueryStr(c, "notify_channel_idents", ""); tmp != "" {
notifyChannelIdents = strings.Split(tmp, ",")
}
notifyChannelIds := str.IdsInt64(ginx.QueryStr(c, "notify_channel_ids", ""))
if len(notifyChannelIds) > 0 {
ginx.Dangerous(models.DB(rt.Ctx).Model(models.NotifyChannelConfig{}).
Where("id in (?)", notifyChannelIds).Pluck("ident", &notifyChannelIdents).Error)
}

me := c.MustGet("user").(*models.User)
gids, err := models.MyGroupIds(rt.Ctx, me.Id)
ginx.Dangerous(err)

lst, err := models.MessageTemplatesGetBy(rt.Ctx, notifyChannelIdents)
ginx.Dangerous(err)

res := make([]*models.MessageTemplate, 0)
for _, t := range lst {
if slice.HaveIntersection[int64](gids, t.UserGroupIds) || t.Private == 0 {
res = append(res, t)
}
}
ginx.NewRender(c).Data(res, nil)
}

type evtMsgReq struct {
EventIds []int64 `json:"event_ids"`
Tpl struct {
Content map[string]string `json:"content"`
} `json:"tpl"`
}

func (rt *Router) eventsMessage(c *gin.Context) {
var req evtMsgReq
ginx.BindJSON(c, &req)

events, err := models.AlertCurEventGetByIds(rt.Ctx, req.EventIds)
ginx.Dangerous(err)
var defs = []string{
"{{$events := .}}",
"{{$event := index . 0}}",
}
ret := make(map[string]string, len(req.Tpl.Content))
for k, v := range req.Tpl.Content {
text := strings.Join(append(defs, v), "")
tpl, err := template.New(k).Funcs(tplx.TemplateFuncMap).Parse(text)
if err != nil {
ret[k] = err.Error()
continue
}

var buf bytes.Buffer
err = tpl.Execute(&buf, events)
if err != nil {
ret[k] = err.Error()
continue
}

ret[k] = buf.String()
}
ginx.NewRender(c).Data(ret, nil)
}

+ 15
- 16
center/router/router_notification_record.go View File

@@ -18,20 +18,17 @@ type NotificationResponse struct {
}

type SubRule struct {
SubID int64 `json:"sub_id"`
Notifies map[string][]Record `json:"notifies"`
}

type Notify struct {
Channel string `json:"channel"`
Records []Record `json:"records"`
SubID int64 `json:"sub_id"`
NotifyRuleId int64 `json:"notify_rule_id"`
Notifies map[string][]Record `json:"notifies"`
}

type Record struct {
Target string `json:"target"`
Username string `json:"username"`
Status int `json:"status"`
Detail string `json:"detail"`
NotifyRuleId int64 `json:"notify_rule_id"`
Target string `json:"target"`
Username string `json:"username"`
Status int `json:"status"`
Detail string `json:"detail"`
}

// notificationRecordAdd
@@ -40,7 +37,7 @@ func (rt *Router) notificationRecordAdd(c *gin.Context) {
ginx.BindJSON(c, &req)
err := sender.PushNotifyRecords(req)
ginx.Dangerous(err, 429)
ginx.NewRender(c).Data(nil, err)
}

@@ -113,9 +110,10 @@ func buildNotificationResponse(ctx *ctx.Context, nl []*models.NotificaitonRecord
n.Target = replaceLastEightChars(n.Target)
}
record := Record{
Target: n.Target,
Status: n.Status,
Detail: n.Details,
Target: n.Target,
Status: n.Status,
Detail: n.Details,
NotifyRuleId: n.NotifyRuleID,
}

record.Username = strings.Join(usernames, ",")
@@ -125,7 +123,8 @@ func buildNotificationResponse(ctx *ctx.Context, nl []*models.NotificaitonRecord
subRule, ok := subRuleMap[n.SubId]
if !ok {
newSubRule := &SubRule{
SubID: n.SubId,
NotifyRuleId: n.NotifyRuleID,
SubID: n.SubId,
}
newSubRule.Notifies = make(map[string][]Record)
newSubRule.Notifies[n.Channel] = []Record{record}


+ 243
- 0
center/router/router_notify_channel.go View File

@@ -0,0 +1,243 @@
package router

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"

"github.com/ccfos/nightingale/v6/models"
"github.com/gin-gonic/gin"
"github.com/toolkits/pkg/ginx"
)

func (rt *Router) notifyChannelsAdd(c *gin.Context) {
me := c.MustGet("user").(*models.User)
if !me.IsAdmin() {
ginx.Bomb(http.StatusForbidden, "no permission")
}

var lst []*models.NotifyChannelConfig
ginx.BindJSON(c, &lst)
if len(lst) == 0 {
ginx.Bomb(http.StatusBadRequest, "input json is empty")
}

idents := make([]string, 0, len(lst))
for _, tpl := range lst {
ginx.Dangerous(tpl.Verify())
idents = append(idents, tpl.Ident)

tpl.CreateBy = me.Username
tpl.CreateAt = time.Now().Unix()
tpl.UpdateBy = me.Username
tpl.UpdateAt = time.Now().Unix()
}
lstWithSameId, err := models.NotifyChannelsGet(rt.Ctx, "ident IN ?", idents)
ginx.Dangerous(err)
if len(lstWithSameId) > 0 {
ginx.Bomb(http.StatusBadRequest, "ident already exists")
}

ginx.Dangerous(models.DB(rt.Ctx).CreateInBatches(lst, 100).Error)
ids := make([]int64, 0, len(lst))
for _, tpl := range lst {
ids = append(ids, tpl.ID)
}
ginx.NewRender(c).Data(ids, nil)
}

func (rt *Router) notifyChannelsDel(c *gin.Context) {
me := c.MustGet("user").(*models.User)
if !me.IsAdmin() {
ginx.Bomb(http.StatusForbidden, "no permission")
}

var f idsForm
ginx.BindJSON(c, &f)
f.Verify()

lst, err := models.NotifyChannelsGet(rt.Ctx, "id in (?)", f.Ids)
ginx.Dangerous(err)
notifyRuleIds, err := models.UsedByNotifyRule(rt.Ctx, models.NotiChList(lst))
ginx.Dangerous(err)
if len(notifyRuleIds) > 0 {
ginx.NewRender(c).Message(fmt.Errorf("used by notify rule: %v", notifyRuleIds))
return
}

ginx.NewRender(c).Message(models.DB(rt.Ctx).
Delete(&models.NotifyChannelConfig{}, "id in (?)", f.Ids).Error)
}

func (rt *Router) notifyChannelPut(c *gin.Context) {
me := c.MustGet("user").(*models.User)
if !me.IsAdmin() {
ginx.Bomb(http.StatusForbidden, "no permission")
}

var f models.NotifyChannelConfig
ginx.BindJSON(c, &f)

nc, err := models.NotifyChannelGet(rt.Ctx, "id = ?", ginx.UrlParamInt64(c, "id"))
ginx.Dangerous(err)
if nc == nil {
ginx.Bomb(http.StatusNotFound, "notify channel not found")
}

f.UpdateBy = me.Username
ginx.NewRender(c).Message(nc.Update(rt.Ctx, f))
}

func (rt *Router) notifyChannelGet(c *gin.Context) {
cid := ginx.UrlParamInt64(c, "id")
nc, err := models.NotifyChannelGet(rt.Ctx, "id = ?", cid)
ginx.Dangerous(err)
if nc == nil {
ginx.Bomb(http.StatusNotFound, "notify channel not found")
}

ginx.NewRender(c).Data(nc, nil)
}

func (rt *Router) notifyChannelGetBy(c *gin.Context) {
ident := ginx.QueryStr(c, "ident")
nc, err := models.NotifyChannelGet(rt.Ctx, "ident = ?", ident)
ginx.Dangerous(err)
if nc == nil {
ginx.Bomb(http.StatusNotFound, "notify channel not found")
}

ginx.NewRender(c).Data(nc, nil)
}

func (rt *Router) notifyChannelsGet(c *gin.Context) {
lst, err := models.NotifyChannelsGet(rt.Ctx, "", nil)
ginx.NewRender(c).Data(lst, err)
}

func (rt *Router) notifyChannelsGetForNormalUser(c *gin.Context) {
lst, err := models.NotifyChannelsGet(rt.Ctx, "")
ginx.Dangerous(err)

newLst := make([]*models.NotifyChannelConfig, 0, len(lst))
for _, c := range lst {
newLst = append(newLst, &models.NotifyChannelConfig{
ID: c.ID,
Name: c.Name,
Ident: c.Ident,
Enable: c.Enable,
RequestType: c.RequestType,
ParamConfig: c.ParamConfig,
})
}
ginx.NewRender(c).Data(newLst, nil)
}

type flushDutyChannelsResponse struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
Data struct {
Items []struct {
ChannelID int `json:"channel_id"`
ChannelName string `json:"channel_name"`
Status string `json:"status"`
} `json:"items"`
Total int `json:"total"`
} `json:"data"`
}

func (rt *Router) flashDutyNotifyChannelsGet(c *gin.Context) {
cid := ginx.UrlParamInt64(c, "id")
nc, err := models.NotifyChannelGet(rt.Ctx, "id = ?", cid)
ginx.Dangerous(err)
if nc == nil {
ginx.Bomb(http.StatusNotFound, "notify channel not found")
}

configs, err := models.ConfigsSelectByCkey(rt.Ctx, "flashduty_app_key")
if err != nil {
ginx.Bomb(http.StatusInternalServerError, "failed to get flashduty app key")
}

jsonData := []byte("{}")
if len(configs) > 0 {
me := c.MustGet("user").(*models.User)
jsonData = []byte(fmt.Sprintf(`{"member_name":"%s","email":"%s","phone":"%s"}`, me.Username, me.Email, me.Phone))
}

items, err := getFlashDutyChannels(nc.RequestConfig.FlashDutyRequestConfig.IntegrationUrl, jsonData)
ginx.Dangerous(err)

ginx.NewRender(c).Data(items, nil)
}

// getFlashDutyChannels 从FlashDuty API获取频道列表
func getFlashDutyChannels(integrationUrl string, jsonData []byte) ([]struct {
ChannelID int `json:"channel_id"`
ChannelName string `json:"channel_name"`
Status string `json:"status"`
}, error) {
// 解析URL,提取baseUrl和参数
baseUrl, integrationKey, err := parseIntegrationUrl(integrationUrl)
if err != nil {
return nil, err
}

if integrationKey == "" {
return nil, fmt.Errorf("integration_key not found in URL")
}

// 构建新的API URL,保持原始路径
url := fmt.Sprintf("%s/channel/list-by-integration?integration_key=%s", baseUrl, integrationKey)

req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}

req.Header.Set("Content-Type", "application/json")
httpResp, err := (&http.Client{}).Do(req)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()

body, err := io.ReadAll(httpResp.Body)
if err != nil {
return nil, err
}

var res flushDutyChannelsResponse
if err := json.Unmarshal(body, &res); err != nil {
return nil, err
}

if res.Error.Message != "" {
return nil, fmt.Errorf(res.Error.Message)
}

return res.Data.Items, nil
}

// parseIntegrationUrl 从URL中提取baseUrl和参数
func parseIntegrationUrl(urlStr string) (baseUrl string, integrationKey string, err error) {
// 解析URL
parsedUrl, err := url.Parse(urlStr)
if err != nil {
return "", "", err
}

host := fmt.Sprintf("%s://%s", parsedUrl.Scheme, parsedUrl.Host)

// 提取查询参数
queryParams := parsedUrl.Query()
integrationKey = queryParams.Get("integration_key")

return host, integrationKey, nil
}

+ 17
- 0
center/router/router_notify_channel_test.go View File

@@ -0,0 +1,17 @@
package router

import (
"fmt"
"testing"
)

func TestGetFlashDutyChannels(t *testing.T) {
// 构造测试数据
integrationUrl := "https://api.flashcat.cloud/event/push/alert/n9e?integration_key=xxx"
jsonData := []byte(`{}`)

// 调用被测试的函数
channels, err := getFlashDutyChannels(integrationUrl, jsonData)

fmt.Println(channels, err)
}

+ 11
- 0
center/router/router_notify_config.go View File

@@ -226,3 +226,14 @@ func (rt *Router) attemptSendEmail(c *gin.Context) {
ginx.NewRender(c).Message(sender.SendEmail("Email test", "email content", []string{f.Email}, smtp))

}

func (rt *Router) notifyChannelConfigGets(c *gin.Context) {

id := ginx.QueryInt64(c, "id", 0)
name := ginx.QueryStr(c, "name", "")
ident := ginx.QueryStr(c, "ident", "")
eabled := ginx.QueryInt(c, "eabled", -1)

notifyChannels, err := models.NotifyChannelGets(rt.Ctx, id, name, ident, eabled)
ginx.NewRender(c).Data(notifyChannels, err)
}

+ 273
- 0
center/router/router_notify_rule.go View File

@@ -0,0 +1,273 @@
package router

import (
"net/http"
"time"

"github.com/ccfos/nightingale/v6/alert/dispatch"
"github.com/ccfos/nightingale/v6/models"
"github.com/ccfos/nightingale/v6/pkg/slice"

"github.com/gin-gonic/gin"
"github.com/pkg/errors"
"github.com/toolkits/pkg/ginx"
"github.com/toolkits/pkg/logger"
)

func (rt *Router) notifyRulesAdd(c *gin.Context) {
var lst []*models.NotifyRule
ginx.BindJSON(c, &lst)
if len(lst) == 0 {
ginx.Bomb(http.StatusBadRequest, "input json is empty")
}

me := c.MustGet("user").(*models.User)
isAdmin := me.IsAdmin()
gids, err := models.MyGroupIds(rt.Ctx, me.Id)
ginx.Dangerous(err)
for _, nr := range lst {
ginx.Dangerous(nr.Verify())
if !isAdmin && !slice.HaveIntersection(gids, nr.UserGroupIds) {
ginx.Bomb(http.StatusForbidden, "no permission")
}

nr.CreateBy = me.Username
nr.CreateAt = time.Now().Unix()
nr.UpdateBy = me.Username
nr.UpdateAt = time.Now().Unix()
}

ginx.Dangerous(models.DB(rt.Ctx).CreateInBatches(lst, 100).Error)
ginx.NewRender(c).Data(lst, nil)
}

func (rt *Router) notifyRulesDel(c *gin.Context) {
var f idsForm
ginx.BindJSON(c, &f)
f.Verify()

if me := c.MustGet("user").(*models.User); !me.IsAdmin() {
lst, err := models.NotifyRulesGet(rt.Ctx, "id in (?)", f.Ids)
ginx.Dangerous(err)
gids, err := models.MyGroupIds(rt.Ctx, me.Id)
ginx.Dangerous(err)
for _, t := range lst {
if !slice.HaveIntersection[int64](gids, t.UserGroupIds) {
ginx.Bomb(http.StatusForbidden, "no permission")
}
}
}

ginx.NewRender(c).Message(models.DB(rt.Ctx).
Delete(&models.NotifyRule{}, "id in (?)", f.Ids).Error)
}

func (rt *Router) notifyRulePut(c *gin.Context) {
var f models.NotifyRule
ginx.BindJSON(c, &f)

nr, err := models.NotifyRuleGet(rt.Ctx, "id = ?", ginx.UrlParamInt64(c, "id"))
ginx.Dangerous(err)
if nr == nil {
ginx.Bomb(http.StatusNotFound, "notify rule not found")
}

me := c.MustGet("user").(*models.User)
gids, err := models.MyGroupIds(rt.Ctx, me.Id)
ginx.Dangerous(err)
if !slice.HaveIntersection[int64](gids, nr.UserGroupIds) {
ginx.Bomb(http.StatusForbidden, "no permission")
}

f.UpdateBy = me.Username
ginx.NewRender(c).Message(nr.Update(rt.Ctx, f))
}

func (rt *Router) notifyRuleGet(c *gin.Context) {
me := c.MustGet("user").(*models.User)
gids, err := models.MyGroupIds(rt.Ctx, me.Id)
ginx.Dangerous(err)

tid := ginx.UrlParamInt64(c, "id")
nr, err := models.NotifyRuleGet(rt.Ctx, "id = ?", tid)
ginx.Dangerous(err)
if nr == nil {
ginx.Bomb(http.StatusNotFound, "notify rule not found")
}
if !slice.HaveIntersection[int64](gids, nr.UserGroupIds) {
ginx.Bomb(http.StatusForbidden, "no permission")
}

ginx.NewRender(c).Data(nr, nil)
}

func (rt *Router) notifyRulesGet(c *gin.Context) {
me := c.MustGet("user").(*models.User)
gids, err := models.MyGroupIds(rt.Ctx, me.Id)
ginx.Dangerous(err)

lst, err := models.NotifyRulesGet(rt.Ctx, "", nil)
ginx.Dangerous(err)

res := make([]*models.NotifyRule, 0)
for _, nr := range lst {
if slice.HaveIntersection[int64](gids, nr.UserGroupIds) {
res = append(res, nr)
}
}
ginx.NewRender(c).Data(res, nil)
}

type NotifyTestForm struct {
EventIDs []int64 `json:"event_ids" binding:"required"`
NotifyConfig models.NotifyConfig `json:"notify_config" binding:"required"`
}

func (rt *Router) notifyTest(c *gin.Context) {
var f NotifyTestForm
ginx.BindJSON(c, &f)

hisEvents, err := models.AlertHisEventGetByIds(rt.Ctx, f.EventIDs)
ginx.Dangerous(err)

if len(hisEvents) == 0 {
ginx.Bomb(http.StatusBadRequest, "event not found")
}

ginx.Dangerous(err)
events := make([]*models.AlertCurEvent, len(hisEvents))
for i, he := range hisEvents {
events[i] = he.ToCur()
}

if !dispatch.NotifyRuleApplicable(&f.NotifyConfig, events[0]) {
ginx.Bomb(http.StatusBadRequest, "event not applicable")
}

notifyChannels, err := models.NotifyChannelGets(rt.Ctx, f.NotifyConfig.ChannelID, "", "", -1)
ginx.Dangerous(err)
if len(notifyChannels) == 0 {
ginx.Bomb(http.StatusBadRequest, "notify channel not found")
}

notifyChannel := notifyChannels[0]

tplContent := make(map[string]string)
if notifyChannel.RequestType != "flashtudy" {
messageTemplates, err := models.MessageTemplateGets(rt.Ctx, f.NotifyConfig.TemplateID, "", "")
ginx.Dangerous(err)
if len(messageTemplates) == 0 {
ginx.Bomb(http.StatusBadRequest, "message template not found")
}
tplContent = messageTemplates[0].RenderEvent(events)
}

userInfos, flashDutyChannelIDs, customParams := dispatch.GetNotifyConfigParams(&f.NotifyConfig, rt.UserCache, rt.UserGroupCache)

var resp string
switch notifyChannel.RequestType {
case "flashduty":
client, err := models.GetHTTPClient(notifyChannel)
ginx.Dangerous(err)

for i := range flashDutyChannelIDs {
resp, err = notifyChannel.SendFlashDuty(events, flashDutyChannelIDs[i], client)
if err != nil {
break
}
}
logger.Infof("channel_name: %v, event:%+v, tplContent:%s, customParams:%v, respBody: %v, err: %v", notifyChannel.Name, events[0], tplContent, customParams, resp, err)
ginx.NewRender(c).Message(err)
case "http":
client, err := models.GetHTTPClient(notifyChannel)
ginx.Dangerous(err)
if notifyChannel.ParamConfig.UserInfo != nil && len(userInfos) > 0 {
for i := range userInfos {
resp, err = notifyChannel.SendHTTP(events, tplContent, customParams, userInfos[i], client)
logger.Infof("channel_name: %v, event:%+v, tplContent:%s, customParams:%v, userInfo:%+v, respBody: %v, err: %v", notifyChannel.Name, events[0], tplContent, customParams, userInfos[i], resp, err)
if err != nil {
logger.Errorf("failed to send http notify: %v", err)
ginx.NewRender(c).Message(err)
break
}
ginx.NewRender(c).Message(err)
}
} else {
resp, err = notifyChannel.SendHTTP(events, tplContent, customParams, nil, client)
logger.Infof("channel_name: %v, event:%+v, tplContent:%s, customParams:%v, respBody: %v, err: %v", notifyChannel.Name, events[0], tplContent, customParams, resp, err)
if err != nil {
logger.Errorf("failed to send http notify: %v", err)
}
ginx.NewRender(c).Message(err)
}
case "smtp":
err := notifyChannel.SendEmail2(events, tplContent, userInfos)
ginx.NewRender(c).Message(err)
case "script":
resp, _, err := notifyChannel.SendScript(events, tplContent, customParams, userInfos)
logger.Infof("channel_name: %v, event:%+v, tplContent:%s, customParams:%v, respBody: %v, err: %v", notifyChannel.Name, events[0], tplContent, customParams, resp, err)
ginx.NewRender(c).Message(err)
default:
logger.Errorf("unsupported request type: %v", notifyChannel.RequestType)
ginx.NewRender(c).Message(errors.New("unsupported request type"))
}
}

type paramList struct {
Name string `json:"name"`
CName string `json:"cname"`
Value interface{} `json:"value"`
}

func (rt *Router) notifyRuleCustomParamsGet(c *gin.Context) {
notifyChannelID := ginx.QueryInt64(c, "notify_channel_id")

me := c.MustGet("user").(*models.User)
gids, err := models.MyGroupIds(rt.Ctx, me.Id)
ginx.Dangerous(err)

notifyChannel, err := models.NotifyChannelGet(rt.Ctx, "id=?", notifyChannelID)
ginx.Dangerous(err)

keyMap := make(map[string]string)
if notifyChannel.ParamConfig == nil {
ginx.NewRender(c).Data([][]paramList{}, nil)
return
}

for _, param := range notifyChannel.ParamConfig.Custom.Params {
keyMap[param.Key] = param.CName
}

lst, err := models.NotifyRulesGet(rt.Ctx, "", nil)
ginx.Dangerous(err)

res := make([][]paramList, 0)
for _, nr := range lst {
if !slice.HaveIntersection[int64](gids, nr.UserGroupIds) {
continue
}

for _, nc := range nr.NotifyConfigs {
if nc.ChannelID != notifyChannelID {
continue
}

list := make([]paramList, 0)
for key, value := range nc.Params {
// 找到在通知媒介中的自定义变量配置项,进行 cname 转换
cname, exsits := keyMap[key]
if exsits {
list = append(list, paramList{
Name: key,
CName: cname,
Value: value,
})
}
}
res = append(res, list)
}
}

ginx.NewRender(c).Data(res, nil)
}

+ 15
- 5
center/router/router_notify_tpl.go View File

@@ -162,10 +162,10 @@ func (rt *Router) notifyTplAdd(c *gin.Context) {
var f models.NotifyTpl
ginx.BindJSON(c, &f)

user := c.MustGet("user").(*models.User)
f.CreateBy = user.Username
f.Channel = strings.TrimSpace(f.Channel)
user := c.MustGet("user").(*models.User)
f.CreateBy = user.Username
f.Channel = strings.TrimSpace(f.Channel)
ginx.Dangerous(templateValidate(f))

count, err := models.Count(models.DB(rt.Ctx).Model(&models.NotifyTpl{}).Where("channel = ? or name = ?", f.Channel, f.Name))
@@ -174,7 +174,7 @@ func (rt *Router) notifyTplAdd(c *gin.Context) {
ginx.Bomb(200, "Refuse to create duplicate channel(unique)")
}

f.CreateAt = time.Now().Unix()
f.CreateAt = time.Now().Unix()
ginx.NewRender(c).Message(f.Create(rt.Ctx))
}

@@ -193,3 +193,13 @@ func (rt *Router) notifyTplDel(c *gin.Context) {

ginx.NewRender(c).Message(f.NotifyTplDelete(rt.Ctx, id))
}

func (rt *Router) messageTemplateGets(c *gin.Context) {
id := ginx.QueryInt64(c, "id", 0)
name := ginx.QueryStr(c, "name", "")
ident := ginx.QueryStr(c, "ident", "")

tpls, err := models.MessageTemplateGets(rt.Ctx, id, name, ident)

ginx.NewRender(c).Data(tpls, err)
}

+ 4
- 1
cmd/edge/edge.go View File

@@ -74,6 +74,9 @@ func Initialize(configDir string, cryptoKey string) (func(), error) {
userCache := memsto.NewUserCache(ctx, syncStats)
userGroupCache := memsto.NewUserGroupCache(ctx, syncStats)
taskTplsCache := memsto.NewTaskTplCache(ctx)
notifyRuleCache := memsto.NewNotifyRuleCache(ctx, syncStats)
notifyChannelCache := memsto.NewNotifyChannelCache(ctx, syncStats)
messageTemplateCache := memsto.NewMessageTemplateCache(ctx, syncStats)

promClients := prom.NewPromClient(ctx)

@@ -82,7 +85,7 @@ func Initialize(configDir string, cryptoKey string) (func(), error) {
externalProcessors := process.NewExternalProcessors()

alert.Start(config.Alert, config.Pushgw, syncStats, alertStats, externalProcessors, targetCache, busiGroupCache, alertMuteCache,
alertRuleCache, notifyConfigCache, taskTplsCache, dsCache, ctx, promClients, userCache, userGroupCache)
alertRuleCache, notifyConfigCache, taskTplsCache, dsCache, ctx, promClients, userCache, userGroupCache, notifyRuleCache, notifyChannelCache, messageTemplateCache)

alertrtRouter := alertrt.New(config.HTTP, config.Alert, alertMuteCache, targetCache, busiGroupCache, alertStats, ctx, externalProcessors)



+ 1
- 1
front/statik/statik.go
File diff suppressed because it is too large
View File


+ 1
- 0
go.mod View File

@@ -39,6 +39,7 @@ require (
github.com/prometheus/prometheus v0.47.1
github.com/rakyll/statik v0.1.7
github.com/redis/go-redis/v9 v9.0.2
github.com/satori/go.uuid v1.2.0
github.com/spaolacci/murmur3 v1.1.0
github.com/stretchr/testify v1.10.0
github.com/tidwall/gjson v1.14.2


+ 2
- 0
go.sum View File

@@ -295,6 +295,8 @@ github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=


+ 139
- 0
memsto/message_template_cache.go View File

@@ -0,0 +1,139 @@
package memsto

import (
"fmt"
"sync"
"time"

"github.com/ccfos/nightingale/v6/dumper"
"github.com/ccfos/nightingale/v6/models"
"github.com/ccfos/nightingale/v6/pkg/ctx"

"github.com/pkg/errors"
"github.com/toolkits/pkg/logger"
)

type MessageTemplateCacheType struct {
statTotal int64
statLastUpdated int64
ctx *ctx.Context
stats *Stats

sync.RWMutex
templates map[int64]*models.MessageTemplate // key: template id
}

func NewMessageTemplateCache(ctx *ctx.Context, stats *Stats) *MessageTemplateCacheType {
mtc := &MessageTemplateCacheType{
statTotal: -1,
statLastUpdated: -1,
ctx: ctx,
stats: stats,
templates: make(map[int64]*models.MessageTemplate),
}
mtc.SyncMessageTemplates()
return mtc
}

func (mtc *MessageTemplateCacheType) Reset() {
mtc.Lock()
defer mtc.Unlock()

mtc.statTotal = -1
mtc.statLastUpdated = -1
mtc.templates = make(map[int64]*models.MessageTemplate)
}

func (mtc *MessageTemplateCacheType) StatChanged(total, lastUpdated int64) bool {
if mtc.statTotal == total && mtc.statLastUpdated == lastUpdated {
return false
}

return true
}

func (mtc *MessageTemplateCacheType) Set(m map[int64]*models.MessageTemplate, total, lastUpdated int64) {
mtc.Lock()
mtc.templates = m
mtc.Unlock()

// only one goroutine used, so no need lock
mtc.statTotal = total
mtc.statLastUpdated = lastUpdated
}

func (mtc *MessageTemplateCacheType) Get(templateId int64) *models.MessageTemplate {
mtc.RLock()
defer mtc.RUnlock()
return mtc.templates[templateId]
}

func (mtc *MessageTemplateCacheType) GetTemplateIds() []int64 {
mtc.RLock()
defer mtc.RUnlock()

count := len(mtc.templates)
list := make([]int64, 0, count)
for templateId := range mtc.templates {
list = append(list, templateId)
}

return list
}

func (mtc *MessageTemplateCacheType) SyncMessageTemplates() {
err := mtc.syncMessageTemplates()
if err != nil {
fmt.Println("failed to sync message templates:", err)
exit(1)
}

go mtc.loopSyncMessageTemplates()
}

func (mtc *MessageTemplateCacheType) loopSyncMessageTemplates() {
duration := time.Duration(9000) * time.Millisecond
for {
time.Sleep(duration)
if err := mtc.syncMessageTemplates(); err != nil {
logger.Warning("failed to sync message templates:", err)
}
}
}

func (mtc *MessageTemplateCacheType) syncMessageTemplates() error {
start := time.Now()
stat, err := models.MessageTemplateStatistics(mtc.ctx)
if err != nil {
dumper.PutSyncRecord("message_templates", start.Unix(), -1, -1, "failed to query statistics: "+err.Error())
return errors.WithMessage(err, "failed to exec MessageTemplateStatistics")
}

if !mtc.StatChanged(stat.Total, stat.LastUpdated) {
mtc.stats.GaugeCronDuration.WithLabelValues("sync_message_templates").Set(0)
mtc.stats.GaugeSyncNumber.WithLabelValues("sync_message_templates").Set(0)
dumper.PutSyncRecord("message_templates", start.Unix(), -1, -1, "not changed")
return nil
}

lst, err := models.MessageTemplateGetsAll(mtc.ctx)
if err != nil {
dumper.PutSyncRecord("message_templates", start.Unix(), -1, -1, "failed to query records: "+err.Error())
return errors.WithMessage(err, "failed to exec MessageTemplateGetsAll")
}

m := make(map[int64]*models.MessageTemplate)
for i := 0; i < len(lst); i++ {
m[lst[i].ID] = lst[i]
}

mtc.Set(m, stat.Total, stat.LastUpdated)

ms := time.Since(start).Milliseconds()
mtc.stats.GaugeCronDuration.WithLabelValues("sync_message_templates").Set(float64(ms))
mtc.stats.GaugeSyncNumber.WithLabelValues("sync_message_templates").Set(float64(len(m)))
logger.Infof("timer: sync message templates done, cost: %dms, number: %d", ms, len(m))
dumper.PutSyncRecord("message_templates", start.Unix(), ms, len(m), "success")

return nil
}

+ 334
- 0
memsto/notify_channel_cache.go View File

@@ -0,0 +1,334 @@
package memsto

import (
"crypto/tls"
"fmt"
"net/http"
"sync"
"time"

"gopkg.in/gomail.v2"

"github.com/ccfos/nightingale/v6/dumper"
"github.com/ccfos/nightingale/v6/models"
"github.com/ccfos/nightingale/v6/pkg/ctx"

"github.com/pkg/errors"
"github.com/toolkits/pkg/logger"
)

type NotifyChannelCacheType struct {
statTotal int64
statLastUpdated int64
ctx *ctx.Context
stats *Stats

sync.RWMutex
channels map[int64]*models.NotifyChannelConfig // key: channel id

httpConcurrency map[int64]chan struct{}

httpClient map[int64]*http.Client
smtpCh map[int64]chan *models.EmailContext
smtpQuitCh map[int64]chan struct{}
}

func NewNotifyChannelCache(ctx *ctx.Context, stats *Stats) *NotifyChannelCacheType {
ncc := &NotifyChannelCacheType{
statTotal: -1,
statLastUpdated: -1,
ctx: ctx,
stats: stats,
channels: make(map[int64]*models.NotifyChannelConfig),
}
ncc.SyncNotifyChannels()
return ncc
}

func (ncc *NotifyChannelCacheType) Reset() {
ncc.Lock()
defer ncc.Unlock()

ncc.statTotal = -1
ncc.statLastUpdated = -1
ncc.channels = make(map[int64]*models.NotifyChannelConfig)
}

func (ncc *NotifyChannelCacheType) StatChanged(total, lastUpdated int64) bool {
if ncc.statTotal == total && ncc.statLastUpdated == lastUpdated {
return false
}

return true
}

func (ncc *NotifyChannelCacheType) Set(m map[int64]*models.NotifyChannelConfig, httpConcurrency map[int64]chan struct{}, httpClient map[int64]*http.Client,
smtpCh map[int64]chan *models.EmailContext, quitCh map[int64]chan struct{}, total, lastUpdated int64) {
ncc.Lock()
for _, k := range ncc.httpConcurrency {
close(k)
}
ncc.httpConcurrency = httpConcurrency
ncc.channels = m
ncc.httpClient = httpClient
ncc.smtpCh = smtpCh

for i := range ncc.smtpQuitCh {
close(ncc.smtpQuitCh[i])
}

ncc.smtpQuitCh = quitCh

ncc.Unlock()

// only one goroutine used, so no need lock
ncc.statTotal = total
ncc.statLastUpdated = lastUpdated
}

func (ncc *NotifyChannelCacheType) Get(channelId int64) *models.NotifyChannelConfig {
ncc.RLock()
defer ncc.RUnlock()
return ncc.channels[channelId]
}

func (ncc *NotifyChannelCacheType) GetHttpClient(channelId int64) *http.Client {
ncc.RLock()
defer ncc.RUnlock()
return ncc.httpClient[channelId]
}

func (ncc *NotifyChannelCacheType) GetSmtpClient(channelId int64) chan *models.EmailContext {
ncc.RLock()
defer ncc.RUnlock()
return ncc.smtpCh[channelId]
}

func (ncc *NotifyChannelCacheType) GetChannelIds() []int64 {
ncc.RLock()
defer ncc.RUnlock()

count := len(ncc.channels)
list := make([]int64, 0, count)
for channelId := range ncc.channels {
list = append(list, channelId)
}

return list
}

func (ncc *NotifyChannelCacheType) SyncNotifyChannels() {
err := ncc.syncNotifyChannels()
if err != nil {
fmt.Println("failed to sync notify channels:", err)
exit(1)
}

go ncc.loopSyncNotifyChannels()
}

func (ncc *NotifyChannelCacheType) loopSyncNotifyChannels() {
duration := time.Duration(9000) * time.Millisecond
for {
time.Sleep(duration)
if err := ncc.syncNotifyChannels(); err != nil {
logger.Warning("failed to sync notify channels:", err)
}
}
}

func (ncc *NotifyChannelCacheType) syncNotifyChannels() error {
start := time.Now()
stat, err := models.NotifyChannelStatistics(ncc.ctx)
if err != nil {
dumper.PutSyncRecord("notify_channels", start.Unix(), -1, -1, "failed to query statistics: "+err.Error())
return errors.WithMessage(err, "failed to exec NotifyChannelStatistics")
}

if !ncc.StatChanged(stat.Total, stat.LastUpdated) {
ncc.stats.GaugeCronDuration.WithLabelValues("sync_notify_channels").Set(0)
ncc.stats.GaugeSyncNumber.WithLabelValues("sync_notify_channels").Set(0)
dumper.PutSyncRecord("notify_channels", start.Unix(), -1, -1, "not changed")
return nil
}

lst, err := models.NotifyChannelGetsAll(ncc.ctx)
if err != nil {
dumper.PutSyncRecord("notify_channels", start.Unix(), -1, -1, "failed to query records: "+err.Error())
return errors.WithMessage(err, "failed to exec NotifyChannelGetsAll")
}

m := make(map[int64]*models.NotifyChannelConfig)
for i := 0; i < len(lst); i++ {
m[lst[i].ID] = lst[i]
}

httpConcurrency := make(map[int64]chan struct{})
httpClient := make(map[int64]*http.Client)
smtpCh := make(map[int64]chan *models.EmailContext)
quitCh := make(map[int64]chan struct{})

for i := range lst {
// todo 优化变更粒度

switch lst[i].RequestType {
case "http":
if lst[i].RequestConfig == nil || lst[i].RequestConfig.HTTPRequestConfig == nil {
logger.Warningf("notify channel %+v http request config not found", lst[i])
continue
}

cli, _ := models.GetHTTPClient(lst[i])
httpClient[lst[i].ID] = cli
httpConcurrency[lst[i].ID] = make(chan struct{}, lst[i].RequestConfig.HTTPRequestConfig.Concurrency)
for j := 0; j < lst[i].RequestConfig.HTTPRequestConfig.Concurrency; j++ {
httpConcurrency[lst[i].ID] <- struct{}{}
}
case "smtp":
ch := make(chan *models.EmailContext)
quit := make(chan struct{})
go ncc.startEmailSender(lst[i].ID, lst[i].RequestConfig.SMTPRequestConfig, ch, quit)
smtpCh[lst[i].ID] = ch
quitCh[lst[i].ID] = quit
default:
}
}

ncc.Set(m, httpConcurrency, httpClient, smtpCh, quitCh, stat.Total, stat.LastUpdated)

ms := time.Since(start).Milliseconds()
ncc.stats.GaugeCronDuration.WithLabelValues("sync_notify_channels").Set(float64(ms))
ncc.stats.GaugeSyncNumber.WithLabelValues("sync_notify_channels").Set(float64(len(m)))
logger.Infof("timer: sync notify channels done, cost: %dms, number: %d", ms, len(m))
dumper.PutSyncRecord("notify_channels", start.Unix(), ms, len(m), "success")

return nil
}

func (ncc *NotifyChannelCacheType) startEmailSender(chID int64, smtp *models.SMTPRequestConfig, ch chan *models.EmailContext, quitCh chan struct{}) {
conf := smtp
if conf.Host == "" || conf.Port == 0 {
logger.Warning("SMTP configurations invalid")
return
}
logger.Infof("start email sender... conf.Host:%+v,conf.Port:%+v", conf.Host, conf.Port)

d := gomail.NewDialer(conf.Host, conf.Port, conf.Username, conf.Password)
if conf.InsecureSkipVerify {
d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
}

var s gomail.SendCloser
var open bool
var size int
for {
select {
case <-quitCh:
return
case m, ok := <-ch:
if !ok {
return
}
if !open {
s = ncc.dialSmtp(quitCh, d)
if s == nil {
// Indicates that the dialing failed and exited the current goroutine directly,
// but put the Message back in the mailch
ch <- m
return
}
open = true
}
var err error
if err = gomail.Send(s, m.Mail); err != nil {
logger.Errorf("email_sender: failed to send: %s", err)

// close and retry
if err := s.Close(); err != nil {
logger.Warningf("email_sender: failed to close smtp connection: %s", err)
}

s = ncc.dialSmtp(quitCh, d)
if s == nil {
// Indicates that the dialing failed and exited the current goroutine directly,
// but put the Message back in the mailch
ch <- m
return
}
open = true

if err = gomail.Send(s, m.Mail); err != nil {
logger.Errorf("email_sender: failed to retry send: %s", err)
}
} else {
logger.Infof("email_sender: result=succ subject=%v to=%v",
m.Mail.GetHeader("Subject"), m.Mail.GetHeader("To"))
}

//for _, to := range m.mail.GetHeader("To") {
// msg := ""
// if err == nil {
// msg = "ok"
// }
// NotifyRecord(ctx, m.events, models.Email, to, msg, err)
//}

size++

if size >= conf.Batch {
if err := s.Close(); err != nil {
logger.Warningf("email_sender: failed to close smtp connection: %s", err)
}
open = false
size = 0
}

// Close the connection to the SMTP server if no email was sent in
// the last 30 seconds.
case <-time.After(30 * time.Second):
if open {
if err := s.Close(); err != nil {
logger.Warningf("email_sender: failed to close smtp connection: %s", err)
}
open = false
}
}
}
}

func (ncc *NotifyChannelCacheType) dialSmtp(quitCh chan struct{}, d *gomail.Dialer) gomail.SendCloser {
for {
select {
case <-quitCh:
// Note that Sendcloser is not obtained below,
// and the outgoing signal (with configuration changes) exits the current dial
return nil
default:
if s, err := d.Dial(); err != nil {
logger.Errorf("email_sender: failed to dial smtp: %s", err)
} else {
return s
}
time.Sleep(time.Second)
}
}
}

func (ncc *NotifyChannelCacheType) HttpConcurrencyAdd(channelId int64) bool {
ncc.RLock()
defer ncc.RUnlock()
if _, ok := ncc.httpConcurrency[channelId]; !ok {
return false
}
_, ok := <-ncc.httpConcurrency[channelId]
return ok
}

func (ncc *NotifyChannelCacheType) HttpConcurrencyDone(channelId int64) {
ncc.RLock()
defer ncc.RUnlock()
if _, ok := ncc.httpConcurrency[channelId]; !ok {
return
}
ncc.httpConcurrency[channelId] <- struct{}{}
}

+ 139
- 0
memsto/notify_rule_cache.go View File

@@ -0,0 +1,139 @@
package memsto

import (
"fmt"
"sync"
"time"

"github.com/ccfos/nightingale/v6/dumper"
"github.com/ccfos/nightingale/v6/models"
"github.com/ccfos/nightingale/v6/pkg/ctx"

"github.com/pkg/errors"
"github.com/toolkits/pkg/logger"
)

type NotifyRuleCacheType struct {
statTotal int64
statLastUpdated int64
ctx *ctx.Context
stats *Stats

sync.RWMutex
rules map[int64]*models.NotifyRule // key: rule id
}

func NewNotifyRuleCache(ctx *ctx.Context, stats *Stats) *NotifyRuleCacheType {
nrc := &NotifyRuleCacheType{
statTotal: -1,
statLastUpdated: -1,
ctx: ctx,
stats: stats,
rules: make(map[int64]*models.NotifyRule),
}
nrc.SyncNotifyRules()
return nrc
}

func (nrc *NotifyRuleCacheType) Reset() {
nrc.Lock()
defer nrc.Unlock()

nrc.statTotal = -1
nrc.statLastUpdated = -1
nrc.rules = make(map[int64]*models.NotifyRule)
}

func (nrc *NotifyRuleCacheType) StatChanged(total, lastUpdated int64) bool {
if nrc.statTotal == total && nrc.statLastUpdated == lastUpdated {
return false
}

return true
}

func (nrc *NotifyRuleCacheType) Set(m map[int64]*models.NotifyRule, total, lastUpdated int64) {
nrc.Lock()
nrc.rules = m
nrc.Unlock()

// only one goroutine used, so no need lock
nrc.statTotal = total
nrc.statLastUpdated = lastUpdated
}

func (nrc *NotifyRuleCacheType) Get(ruleId int64) *models.NotifyRule {
nrc.RLock()
defer nrc.RUnlock()
return nrc.rules[ruleId]
}

func (nrc *NotifyRuleCacheType) GetRuleIds() []int64 {
nrc.RLock()
defer nrc.RUnlock()

count := len(nrc.rules)
list := make([]int64, 0, count)
for ruleId := range nrc.rules {
list = append(list, ruleId)
}

return list
}

func (nrc *NotifyRuleCacheType) SyncNotifyRules() {
err := nrc.syncNotifyRules()
if err != nil {
fmt.Println("failed to sync notify rules:", err)
exit(1)
}

go nrc.loopSyncNotifyRules()
}

func (nrc *NotifyRuleCacheType) loopSyncNotifyRules() {
duration := time.Duration(9000) * time.Millisecond
for {
time.Sleep(duration)
if err := nrc.syncNotifyRules(); err != nil {
logger.Warning("failed to sync notify rules:", err)
}
}
}

func (nrc *NotifyRuleCacheType) syncNotifyRules() error {
start := time.Now()
stat, err := models.NotifyRuleStatistics(nrc.ctx)
if err != nil {
dumper.PutSyncRecord("notify_rules", start.Unix(), -1, -1, "failed to query statistics: "+err.Error())
return errors.WithMessage(err, "failed to exec NotifyRuleStatistics")
}

if !nrc.StatChanged(stat.Total, stat.LastUpdated) {
nrc.stats.GaugeCronDuration.WithLabelValues("sync_notify_rules").Set(0)
nrc.stats.GaugeSyncNumber.WithLabelValues("sync_notify_rules").Set(0)
dumper.PutSyncRecord("notify_rules", start.Unix(), -1, -1, "not changed")
return nil
}

lst, err := models.NotifyRuleGetsAll(nrc.ctx)
if err != nil {
dumper.PutSyncRecord("notify_rules", start.Unix(), -1, -1, "failed to query records: "+err.Error())
return errors.WithMessage(err, "failed to exec NotifyRuleGetsAll")
}

m := make(map[int64]*models.NotifyRule)
for i := 0; i < len(lst); i++ {
m[lst[i].ID] = lst[i]
}

nrc.Set(m, stat.Total, stat.LastUpdated)

ms := time.Since(start).Milliseconds()
nrc.stats.GaugeCronDuration.WithLabelValues("sync_notify_rules").Set(float64(ms))
nrc.stats.GaugeSyncNumber.WithLabelValues("sync_notify_rules").Set(float64(len(m)))
logger.Infof("timer: sync notify rules done, cost: %dms, number: %d", ms, len(m))
dumper.PutSyncRecord("notify_rules", start.Unix(), ms, len(m), "success")

return nil
}

+ 61
- 1
models/alert_cur_event.go View File

@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"reflect"
"strconv"
"strings"
"text/template"
@@ -73,6 +74,65 @@ type AlertCurEvent struct {
RecoverConfig RecoverConfig `json:"recover_config" gorm:"-"`
RuleHash string `json:"rule_hash" gorm:"-"`
ExtraInfoMap []map[string]string `json:"extra_info_map" gorm:"-"`
NotifyRuleIDs []int64 `json:"notify_rule_ids" gorm:"-"`
}

func (e *AlertCurEvent) JsonTagsAndValue() map[string]string {
v := reflect.ValueOf(e).Elem()
t := v.Type()
tags := make(map[string]string)

for i := 0; i < t.NumField(); i++ {
field := t.Field(i)

// 获取 json tag
tag := field.Tag.Get("json")
if tag == "" {
continue
}

// 处理类似 `json:",omitempty"` 或 `json:"-"` 的特殊情况
tagParts := strings.Split(tag, ",")
if tagParts[0] == "-" {
continue
}

// 获取字段值并转换为字符串
fieldValue := v.Field(i).Interface()
var strValue string

switch v := fieldValue.(type) {
case string:
strValue = v
case int, int8, int16, int32, int64:
strValue = fmt.Sprintf("%d", v)
case float32, float64:
strValue = fmt.Sprintf("%f", v)
case bool:
strValue = fmt.Sprintf("%v", v)
case []string:
b, _ := json.Marshal(v)
strValue = string(b)
case map[string]string:
b, _ := json.Marshal(v)
strValue = string(b)
default:
// 对于其他类型,尝试 JSON 序列化
if b, err := json.Marshal(v); err == nil {
strValue = string(b)
} else {
strValue = fmt.Sprintf("%v", v)
}
}

// 如果没有指定 tag 名称,使用字段名作为 key
if tagParts[0] == "" {
tags[field.Name] = strValue
} else {
tags[tagParts[0]] = strValue
}
}
return tags
}

type EventTriggerValues struct {
@@ -633,7 +693,7 @@ func AlertCurEventGetByIds(ctx *ctx.Context, ids []int64) ([]*AlertCurEvent, err
return lst, nil
}

err := DB(ctx).Where("id in ?", ids).Order("trigger_time desc").Find(&lst).Error
err := DB(ctx).Model(&AlertCurEvent{}).Where("id in ?", ids).Order("trigger_time desc").Find(&lst).Error
if err == nil {
for i := 0; i < len(lst); i++ {
lst[i].DB2FE()


+ 56
- 0
models/alert_his_event.go View File

@@ -341,3 +341,59 @@ func EventPersist(ctx *ctx.Context, event *AlertCurEvent) error {

return nil
}

func AlertHisEventGetByIds(ctx *ctx.Context, ids []int64) ([]*AlertHisEvent, error) {
var lst []*AlertHisEvent

if len(ids) == 0 {
return lst, nil
}

err := DB(ctx).Where("id in ?", ids).Order("trigger_time desc").Find(&lst).Error
if err == nil {
for i := 0; i < len(lst); i++ {
lst[i].DB2FE()
}
}

return lst, err
}

func (e *AlertHisEvent) ToCur() *AlertCurEvent {
return &AlertCurEvent{
Id: e.Id,
Cate: e.Cate,
Cluster: e.Cluster,
DatasourceId: e.DatasourceId,
GroupId: e.GroupId,
GroupName: e.GroupName,
Hash: e.Hash,
RuleId: e.RuleId,
RuleName: e.RuleName,
RuleProd: e.RuleProd,
RuleAlgo: e.RuleAlgo,
RuleNote: e.RuleNote,
Severity: e.Severity,
PromForDuration: e.PromForDuration,
PromQl: e.PromQl,
PromEvalInterval: e.PromEvalInterval,
RuleConfig: e.RuleConfig,
RuleConfigJson: e.RuleConfigJson,
Callbacks: e.Callbacks,
RunbookUrl: e.RunbookUrl,
NotifyRecovered: e.NotifyRecovered,
NotifyChannels: e.NotifyChannels,
NotifyGroups: e.NotifyGroups,
Annotations: e.Annotations,
AnnotationsJSON: e.AnnotationsJSON,
TargetIdent: e.TargetIdent,
TargetNote: e.TargetNote,
TriggerTime: e.TriggerTime,
TriggerValue: e.TriggerValue,
Tags: e.Tags,
OriginalTags: e.OriginalTags,
LastEvalTime: e.LastEvalTime,
NotifyCurNumber: e.NotifyCurNumber,
FirstTriggerTime: e.FirstTriggerTime,
}
}

+ 32
- 0
models/alert_mute.go View File

@@ -23,6 +23,38 @@ type TagFilter struct {
Vset map[string]struct{} // parse value to regexp if func = 'in' or 'not in'
}

func (t *TagFilter) Verify() error {
if t.Key == "" {
return errors.New("tag key cannot be empty")
}

if t.Func != "==" && t.Func != "!=" && t.Func != "in" && t.Func != "not in" &&
t.Func != "=~" && t.Func != "!~" {
return errors.New("invalid operation")
}

return nil
}

func ParseTagFilter(bFilters []TagFilter) ([]TagFilter, error) {
var err error
for i := 0; i < len(bFilters); i++ {
if bFilters[i].Func == "=~" || bFilters[i].Func == "!~" {
bFilters[i].Regexp, err = regexp.Compile(bFilters[i].Value)
if err != nil {
return nil, err
}
} else if bFilters[i].Func == "in" || bFilters[i].Func == "not in" {
arr := strings.Fields(bFilters[i].Value)
bFilters[i].Vset = make(map[string]struct{})
for j := 0; j < len(arr); j++ {
bFilters[i].Vset[arr[j]] = struct{}{}
}
}
}
return bFilters, nil
}

func GetTagFilters(jsonArr ormx.JSONArr) ([]TagFilter, error) {
if jsonArr == nil || len([]byte(jsonArr)) == 0 {
return []TagFilter{}, nil


+ 24
- 0
models/alert_rule.go View File

@@ -107,6 +107,8 @@ type AlertRule struct {
CurEventCount int64 `json:"cur_event_count" gorm:"-"`
UpdateByNickname string `json:"update_by_nickname" gorm:"-"` // for fe
CronPattern string `json:"cron_pattern"`
NotifyRuleIds []int64 `json:"notify_rule_ids" gorm:"serializer:json"`
NotifyVersion int `json:"notify_version"` // 0: old, 1: new
}

type ChildVarConfig struct {
@@ -511,6 +513,14 @@ func (ar *AlertRule) Verify() error {
return err
}

if len(ar.NotifyRuleIds) > 0 {
ar.NotifyVersion = 1
ar.NotifyChannelsJSON = []string{}
ar.NotifyGroupsJSON = []string{}
ar.NotifyChannels = ""
ar.NotifyGroups = ""
}

return nil
}

@@ -696,6 +706,16 @@ func (ar *AlertRule) UpdateColumn(ctx *ctx.Context, column string, value interfa
return DB(ctx).Model(ar).UpdateColumn("annotations", string(b)).Error
}

if column == "notify_rule_ids" {
updates := map[string]interface{}{
"notify_version": 1,
"notify_channels": "",
"notify_groups": "",
"notify_rule_ids": value,
}
return DB(ctx).Model(ar).Updates(updates).Error
}

return DB(ctx).Model(ar).UpdateColumn(column, value).Error
}

@@ -900,6 +920,10 @@ func (ar *AlertRule) DB2FE() error {
ar.EnableDaysOfWeekJSON = ar.EnableDaysOfWeeksJSON[0]
}

if ar.NotifyRuleIds == nil {
ar.NotifyRuleIds = make([]int64, 0)
}

ar.NotifyChannelsJSON = strings.Fields(ar.NotifyChannels)
ar.NotifyGroupsJSON = strings.Fields(ar.NotifyGroups)
ar.CallbacksJSON = strings.Fields(ar.Callbacks)


+ 2
- 0
models/alert_subscribe.go View File

@@ -49,6 +49,8 @@ type AlertSubscribe struct {
BusiGroups ormx.JSONArr `json:"busi_groups"`
IBusiGroups []TagFilter `json:"-" gorm:"-"` // inner busiGroups
RuleIds []int64 `json:"rule_ids" gorm:"serializer:json"`
NotifyRuleIds []int64 `json:"notify_rule_ids" gorm:"serializer:json"`
NotifyVersion int `json:"notify_version"`
RuleNames []string `json:"rule_names" gorm:"-"`
}



+ 628
- 0
models/message_tpl.go View File

@@ -0,0 +1,628 @@
package models

import (
"bytes"
"strings"
"text/template"
"time"

"github.com/ccfos/nightingale/v6/pkg/ctx"
"github.com/ccfos/nightingale/v6/pkg/poster"
"github.com/ccfos/nightingale/v6/pkg/tplx"

"github.com/pkg/errors"
"github.com/toolkits/pkg/logger"
)

// MessageTemplate 消息模板结构
type MessageTemplate struct {
ID int64 `json:"id" gorm:"primarykey"`
Name string `json:"name"` // 模板名称
Ident string `json:"ident"` // 模板标识
Content map[string]string `json:"content" gorm:"serializer:json"` // 模板内容
UserGroupIds []int64 `json:"user_group_ids" gorm:"serializer:json"`
NotifyChannelIdent string `json:"notify_channel_ident"` // 通知媒介 Ident
Private int `json:"private"` // 0-公开 1-私有
CreateAt int64 `json:"create_at"`
CreateBy string `json:"create_by"`
UpdateAt int64 `json:"update_at"`
UpdateBy string `json:"update_by"`
}

func MessageTemplateStatistics(ctx *ctx.Context) (*Statistics, error) {
if !ctx.IsCenter {
s, err := poster.GetByUrls[*Statistics](ctx, "/v1/n9e/statistic?name=message_template")
return s, err
}

session := DB(ctx).Model(&MessageTemplate{}).Select("count(*) as total", "max(update_at) as last_updated")

var stats []*Statistics
err := session.Find(&stats).Error
if err != nil {
return nil, err
}

return stats[0], nil
}

func MessageTemplateGetsAll(ctx *ctx.Context) ([]*MessageTemplate, error) {
if !ctx.IsCenter {
templates, err := poster.GetByUrls[[]*MessageTemplate](ctx, "/v1/n9e/message-templates-v2")
return templates, err
}

var templates []*MessageTemplate
err := DB(ctx).Find(&templates).Error
if err != nil {
return nil, err
}

return templates, nil
}

func MessageTemplateGets(ctx *ctx.Context, id int64, name, ident string) ([]*MessageTemplate, error) {
session := DB(ctx)

if id != 0 {
session = session.Where("id = ?", id)
}

if name != "" {
session = session.Where("name = ?", name)
}

if ident != "" {
session = session.Where("ident = ?", ident)
}

var templates []*MessageTemplate
err := session.Find(&templates).Error

return templates, err
}

func (t *MessageTemplate) TableName() string {
return "message_template"
}

func (t *MessageTemplate) Verify() error {
if t.Name == "" {
return errors.New("template name cannot be empty")
}

if t.Ident == "" {
return errors.New("template identifier cannot be empty")
}

for key := range t.Content {
if key == "" {
return errors.New("template content cannot have empty keys")
}
}

if t.Private == 1 && len(t.UserGroupIds) == 0 {
return errors.New("user group IDs of private msg tpl cannot be empty")
}

if t.Private != 0 && t.Private != 1 {
return errors.New("private flag must be 0 or 1")
}

return nil
}

func (t *MessageTemplate) Update(ctx *ctx.Context, ref MessageTemplate) error {
// ref.FE2DB()
if t.Ident != ref.Ident {
return errors.New("cannot update ident")
}

ref.ID = t.ID
ref.CreateAt = t.CreateAt
ref.CreateBy = t.CreateBy
ref.UpdateAt = time.Now().Unix()

err := ref.Verify()
if err != nil {
return err
}
return DB(ctx).Model(t).Select("*").Updates(ref).Error
}

func (t *MessageTemplate) DB2FE() {
if t.UserGroupIds == nil {
t.UserGroupIds = make([]int64, 0)
}
}

func MessageTemplateGet(ctx *ctx.Context, where string, args ...interface{}) (*MessageTemplate, error) {
lst, err := MessageTemplatesGet(ctx, where, args...)
if err != nil || len(lst) == 0 {
return nil, err
}
return lst[0], err
}

func MessageTemplatesGet(ctx *ctx.Context, where string, args ...interface{}) ([]*MessageTemplate, error) {
lst := make([]*MessageTemplate, 0)
session := DB(ctx)
if where != "" && len(args) > 0 {
session = session.Where(where, args...)
}

err := session.Find(&lst).Error
if err != nil {
return nil, err
}
for _, t := range lst {
t.DB2FE()
}
return lst, nil
}

func MessageTemplatesGetBy(ctx *ctx.Context, notifyChannelIdents []string) ([]*MessageTemplate, error) {
lst := make([]*MessageTemplate, 0)
session := DB(ctx)
if len(notifyChannelIdents) > 0 {
session = session.Where("notify_channel_ident IN (?)", notifyChannelIdents)
}

err := session.Find(&lst).Error
if err != nil {
return nil, err
}
for _, t := range lst {
t.DB2FE()
}
return lst, nil
}

type MsgTplList []*MessageTemplate

func (t MsgTplList) GetIdentSet() map[int64]struct{} {
idents := make(map[int64]struct{}, len(t))
for _, tpl := range t {
idents[tpl.ID] = struct{}{}
}
return idents
}

func (t MsgTplList) IfUsed(nr *NotifyRule) bool {
identSet := t.GetIdentSet()
for _, nc := range nr.NotifyConfigs {
if _, ok := identSet[nc.TemplateID]; ok {
return true
}
}
return false
}

const (
DingtalkTitle = `{{if $event.IsRecovered}} Recovered {{else}}Triggered{{end}}: {{$event.RuleName}}`
FeishuCardTitle = `🔔 {{$event.RuleName}}`
LarkCardTitle = `🔔 {{$event.RuleName}}`
)

var NewTplMap = map[string]string{
"ali-voice": `{"alert_name":"{{$event.RuleName}},级别状态 S{{$event.Severity}} {{if $event.IsRecovered}}Recovered{{else}}Triggered{{end}}"}`,
"ali-sms": `{"name":"级别状态 S{{$event.Severity}} {{if $event.IsRecovered}}Recovered{{else}}Triggered{{end}} 规则名称 {{$event.RuleName}}"`,
"tx-voice": `S{{$event.Severity}}{{if $event.IsRecovered}}Recovered{{else}}Triggered{{end}}{{$event.RuleName}}`,
"tx-sms": `级别状态: S{{$event.Severity}} {{if $event.IsRecovered}}Recovered{{else}}Triggered{{end}}规则名称: {{$event.RuleName}}`,
Dingtalk: `#### {{if $event.IsRecovered}}💚{{$event.RuleName}}{{else}}💔{{$event.RuleName}}{{end}}
---
{{$time_duration := sub now.Unix $event.FirstTriggerTime }}{{if $event.IsRecovered}}{{$time_duration = sub $event.LastEvalTime $event.FirstTriggerTime }}{{end}}
- **告警级别**: {{$event.Severity}}级
{{- if $event.RuleNote}}
- **规则备注**: {{$event.RuleNote}}
{{- end}}
{{- if not $event.IsRecovered}}
- **当次触发时值**: {{$event.TriggerValue}}
- **当次触发时间**: {{timeformat $event.TriggerTime}}
- **告警持续时长**: {{humanizeDurationInterface $time_duration}}
{{- else}}
{{- if $event.AnnotationsJSON.recovery_value}}
- **恢复时值**: {{formatDecimal $event.AnnotationsJSON.recovery_value 4}}
{{- end}}
- **恢复时间**: {{timeformat $event.LastEvalTime}}
- **告警持续时长**: {{humanizeDurationInterface $time_duration}}
{{- end}}
- **告警事件标签**:
{{- range $key, $val := $event.TagsMap}}
{{- if ne $key "rulename" }}
- {{$key}}: {{$val}}
{{- end}}
{{- end}}
{{$domain := "http://请联系管理员修改通知模板将域名替换为实际的域名" }}
[事件详情]({{$domain}}/alert-his-events/{{$event.Id}})|[屏蔽1小时]({{$domain}}/alert-mutes/add?busiGroup={{$event.GroupId}}&cate={{$event.Cate}}&datasource_ids={{$event.DatasourceId}}&prod={{$event.RuleProd}}{{range $key, $value := $event.TagsMap}}&tags={{$key}}%3D{{$value}}{{end}})|[查看曲线]({{$domain}}/metric/explorer?data_source_id={{$event.DatasourceId}}&data_source_name=prometheus&mode=graph&prom_ql={{$event.PromQl|escape}})`,
Email: `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>夜莺告警通知</title>
<style type="text/css">
.wrapper {
background-color: #f8f8f8;
padding: 15px;
height: 100%;
}
.main {
width: 600px;
padding: 30px;
margin: 0 auto;
background-color: #fff;
font-size: 12px;
font-family: verdana,'Microsoft YaHei',Consolas,'Deja Vu Sans Mono','Bitstream Vera Sans Mono';
}
header {
border-radius: 2px 2px 0 0;
}
header .title {
font-size: 14px;
color: #333333;
margin: 0;
}
header .sub-desc {
color: #333;
font-size: 14px;
margin-top: 6px;
margin-bottom: 0;
}
hr {
margin: 20px 0;
height: 0;
border: none;
border-top: 1px solid #e5e5e5;
}
em {
font-weight: 600;
}
table {
margin: 20px 0;
width: 100%;
}
table tbody tr{
font-weight: 200;
font-size: 12px;
color: #666;
height: 32px;
}
.succ {
background-color: green;
color: #fff;
}
.fail {
background-color: red;
color: #fff;
}
.succ th, .succ td, .fail th, .fail td {
color: #fff;
}
table tbody tr th {
width: 80px;
text-align: right;
}
.text-right {
text-align: right;
}
.body {
margin-top: 24px;
}
.body-text {
color: #666666;
-webkit-font-smoothing: antialiased;
}
.body-extra {
-webkit-font-smoothing: antialiased;
}
.body-extra.text-right a {
text-decoration: none;
color: #333;
}
.body-extra.text-right a:hover {
color: #666;
}
.button {
width: 200px;
height: 50px;
margin-top: 20px;
text-align: center;
border-radius: 2px;
background: #2D77EE;
line-height: 50px;
font-size: 20px;
color: #FFFFFF;
cursor: pointer;
}
.button:hover {
background: rgb(25, 115, 255);
border-color: rgb(25, 115, 255);
color: #fff;
}
footer {
margin-top: 10px;
text-align: right;
}
.footer-logo {
text-align: right;
}
.footer-logo-image {
width: 108px;
height: 27px;
margin-right: 10px;
}
.copyright {
margin-top: 10px;
font-size: 12px;
text-align: right;
color: #999;
-webkit-font-smoothing: antialiased;
}
</style>
</head>
<body>
<div class="wrapper">
<div class="main">
<header>
<h3 class="title">{{$event.RuleName}}</h3>
<p class="sub-desc"></p>
</header>
<hr>
<div class="body">
<table cellspacing="0" cellpadding="0" border="0">
<tbody>
{{if $event.IsRecovered}}
<tr class="succ">
<th>级别状态:</th>
<td>S{{$event.Severity}} Recovered</td>
</tr>
{{else}}
<tr class="fail">
<th>级别状态:</th>
<td>S{{$event.Severity}} Triggered</td>
</tr>
{{end}}
<tr>
<th>策略备注:</th>
<td>{{$event.RuleNote}}</td>
</tr>
<tr>
<th>设备备注:</th>
<td>{{$event.TargetNote}}</td>
</tr>
{{if not $event.IsRecovered}}
<tr>
<th>触发时值:</th>
<td>{{$event.TriggerValue}}</td>
</tr>
{{end}}
{{if $event.TargetIdent}}
<tr>
<th>监控对象:</th>
<td>{{$event.TargetIdent}}</td>
</tr>
{{end}}
<tr>
<th>监控指标:</th>
<td>{{$event.TagsJSON}}</td>
</tr>
{{if $event.IsRecovered}}
<tr>
<th>恢复时间:</th>
<td>{{timeformat $event.LastEvalTime}}</td>
</tr>
{{else}}
<tr>
<th>触发时间:</th>
<td>
{{timeformat $event.TriggerTime}}
</td>
</tr>
{{end}}
<tr>
<th>发送时间:</th>
<td>
{{timestamp}}
</td>
</tr>
</tbody>
</table>
<hr>
<footer>
<div class="copyright" style="font-style: italic">
报警太多?使用 <a href="https://flashcat.cloud/product/flashduty/" target="_blank">FlashDuty</a> 做告警聚合降噪、排班OnCall!
</div>
</footer>
</div>
</div>
</div>
</body>
</html>`,
Feishu: `级别状态: S{{$event.Severity}} {{if $event.IsRecovered}}Recovered{{else}}Triggered{{end}}
规则名称: {{$event.RuleName}}{{if $event.RuleNote}}
规则备注: {{$event.RuleNote}}{{end}}
监控指标: {{$event.TagsJSON}}
{{if $event.IsRecovered}}恢复时间:{{timeformat $event.LastEvalTime}}{{else}}触发时间: {{timeformat $event.TriggerTime}}
触发时值: {{$event.TriggerValue}}{{end}}
发送时间: {{timestamp}}
{{$domain := "http://请联系管理员修改通知模板将域名替换为实际的域名" }}
事件详情: {{$domain}}/alert-his-events/{{$event.Id}}
屏蔽1小时: {{$domain}}/alert-mutes/add?busiGroup={{$event.GroupId}}&cate={{$event.Cate}}&datasource_ids={{$event.DatasourceId}}&prod={{$event.RuleProd}}{{range $key, $value := $event.TagsMap}}&tags={{$key}}%3D{{$value}}{{end}}`,
FeishuCard: `{{ if $event.IsRecovered }}
{{- if ne $event.Cate "host"}}
**告警集群:** {{$event.Cluster}}{{end}}
**级别状态:** S{{$event.Severity}} Recovered
**告警名称:** {{$event.RuleName}}
**恢复时间:** {{timeformat $event.LastEvalTime}}
**告警描述:** **服务已恢复**
{{- else }}
{{- if ne $event.Cate "host"}}
**告警集群:** {{$event.Cluster}}{{end}}
**级别状态:** S{{$event.Severity}} Triggered
**告警名称:** {{$event.RuleName}}
**触发时间:** {{timeformat $event.TriggerTime}}
**发送时间:** {{timestamp}}
**触发时值:** {{$event.TriggerValue}}
{{if $event.RuleNote }}**告警描述:** **{{$event.RuleNote}}**{{end}}
{{- end -}}
{{$domain := "http://请联系管理员修改通知模板将域名替换为实际的域名" }}
[事件详情]({{$domain}}/alert-his-events/{{$event.Id}})|[屏蔽1小时]({{$domain}}/alert-mutes/add?busiGroup={{$event.GroupId}}&cate={{$event.Cate}}&datasource_ids={{$event.DatasourceId}}&prod={{$event.RuleProd}}{{range $key, $value := $event.TagsMap}}&tags={{$key}}%3D{{$value}}{{end}})|[查看曲线]({{$domain}}/metric/explorer?data_source_id={{$event.DatasourceId}}&data_source_name=prometheus&mode=graph&prom_ql={{$event.PromQl|escape}})`,
EmailSubject: `{{if $event.IsRecovered}}Recovered{{else}}Triggered{{end}}: {{$event.RuleName}} {{$event.TagsJSON}}`,
Mm: `级别状态: S{{$event.Severity}} {{if $event.IsRecovered}}Recovered{{else}}Triggered{{end}}
规则名称: {{$event.RuleName}}{{if $event.RuleNote}}
规则备注: {{$event.RuleNote}}{{end}}
监控指标: {{$event.TagsJSON}}
{{if $event.IsRecovered}}恢复时间:{{timeformat $event.LastEvalTime}}{{else}}触发时间: {{timeformat $event.TriggerTime}}
触发时值: {{$event.TriggerValue}}{{end}}
发送时间: {{timestamp}}`,
Telegram: `**级别状态**: {{if $event.IsRecovered}}<font color="info">S{{$event.Severity}} Recovered</font>{{else}}<font color="warning">S{{$event.Severity}} Triggered</font>{{end}}
**规则标题**: {{$event.RuleName}}{{if $event.RuleNote}}
**规则备注**: {{$event.RuleNote}}{{end}}{{if $event.TargetIdent}}
**监控对象**: {{$event.TargetIdent}}{{end}}
**监控指标**: {{$event.TagsJSON}}{{if not $event.IsRecovered}}
**触发时值**: {{$event.TriggerValue}}{{end}}
{{if $event.IsRecovered}}**恢复时间**: {{timeformat $event.LastEvalTime}}{{else}}**首次触发时间**: {{timeformat $event.FirstTriggerTime}}{{end}}
{{$time_duration := sub now.Unix $event.FirstTriggerTime }}{{if $event.IsRecovered}}{{$time_duration = sub $event.LastEvalTime $event.FirstTriggerTime }}{{end}}**距离首次告警**: {{humanizeDurationInterface $time_duration}}
**发送时间**: {{timestamp}}`,
Wecom: `**级别状态**: {{if $event.IsRecovered}}S{{$event.Severity}} Recovered{{else}}S{{$event.Severity}} Triggered{{end}}
**规则标题**: {{$event.RuleName}}{{if $event.RuleNote}}
**规则备注**: {{$event.RuleNote}}{{end}}{{if $event.TargetIdent}}
**监控对象**: {{$event.TargetIdent}}{{end}}
**监控指标**: {{$event.TagsJSON}}{{if not $event.IsRecovered}}
**触发时值**: {{$event.TriggerValue}}{{end}}
{{if $event.IsRecovered}}**恢复时间**: {{timeformat $event.LastEvalTime}}{{else}}**首次触发时间**: {{timeformat $event.FirstTriggerTime}}{{end}}
{{$time_duration := sub now.Unix $event.FirstTriggerTime }}{{if $event.IsRecovered}}{{$time_duration = sub $event.LastEvalTime $event.FirstTriggerTime }}{{end}}**距离首次告警**: {{humanizeDurationInterface $time_duration}}
**发送时间**: {{timestamp}}
{{$domain := "http://请联系管理员修改通知模板将域名替换为实际的域名" }}
[事件详情]({{$domain}}/alert-his-events/{{$event.Id}})|[屏蔽1小时]({{$domain}}/alert-mutes/add?busiGroup={{$event.GroupId}}&cate={{$event.Cate}}&datasource_ids={{$event.DatasourceId}}&prod={{$event.RuleProd}}{{range $key, $value := $event.TagsMap}}&tags={{$key}}%3D{{$value}}{{end}})|[查看曲线]({{$domain}}/metric/explorer?data_source_id={{$event.DatasourceId}}&data_source_name=prometheus&mode=graph&prom_ql={{$event.PromQl|escape}})`,
Lark: `级别状态: S{{$event.Severity}} {{if $event.IsRecovered}}Recovered{{else}}Triggered{{end}}
规则名称: {{$event.RuleName}}{{if $event.RuleNote}}
规则备注: {{$event.RuleNote}}{{end}}
监控指标: {{$event.TagsJSON}}
{{if $event.IsRecovered}}恢复时间:{{timeformat $event.LastEvalTime}}{{else}}触发时间: {{timeformat $event.TriggerTime}}
触发时值: {{$event.TriggerValue}}{{end}}
发送时间: {{timestamp}}
{{$domain := "http://请联系管理员修改通知模板将域名替换为实际的域名" }}
事件详情: {{$domain}}/alert-his-events/{{$event.Id}}
屏蔽1小时: {{$domain}}/alert-mutes/add?busiGroup={{$event.GroupId}}&cate={{$event.Cate}}&datasource_ids={{$event.DatasourceId}}&prod={{$event.RuleProd}}{{range $key, $value := $event.TagsMap}}&tags={{$key}}%3D{{$value}}{{end}}`,
LarkCard: `{{ if $event.IsRecovered }}
{{- if ne $event.Cate "host"}}
**告警集群:** {{$event.Cluster}}{{end}}
**级别状态:** S{{$event.Severity}} Recovered
**告警名称:** {{$event.RuleName}}
**恢复时间:** {{timeformat $event.LastEvalTime}}
{{$time_duration := sub now.Unix $event.FirstTriggerTime }}{{if $event.IsRecovered}}{{$time_duration = sub $event.LastEvalTime $event.FirstTriggerTime }}{{end}}**持续时长**: {{humanizeDurationInterface $time_duration}}
**告警描述:** **服务已恢复**
{{- else }}
{{- if ne $event.Cate "host"}}
**告警集群:** {{$event.Cluster}}{{end}}
**级别状态:** S{{$event.Severity}} Triggered
**告警名称:** {{$event.RuleName}}
**触发时间:** {{timeformat $event.TriggerTime}}
**发送时间:** {{timestamp}}
**触发时值:** {{$event.TriggerValue}}
{{$time_duration := sub now.Unix $event.FirstTriggerTime }}{{if $event.IsRecovered}}{{$time_duration = sub $event.LastEvalTime $event.FirstTriggerTime }}{{end}}**持续时长**: {{humanizeDurationInterface $time_duration}}
{{if $event.RuleNote }}**告警描述:** **{{$event.RuleNote}}**{{end}}
{{- end -}}
{{$domain := "http://请联系管理员修改通知模板将域名替换为实际的域名" }}
[事件详情]({{$domain}}/alert-his-events/{{$event.Id}})|[屏蔽1小时]({{$domain}}/alert-mutes/add?busiGroup={{$event.GroupId}}&cate={{$event.Cate}}&datasource_ids={{$event.DatasourceId}}&prod={{$event.RuleProd}}{{range $key, $value := $event.TagsMap}}&tags={{$key}}%3D{{$value}}{{end}})|[查看曲线]({{$domain}}/metric/explorer?data_source_id={{$event.DatasourceId}}&data_source_name=prometheus&mode=graph&prom_ql={{$event.PromQl|escape}})`,
}

var MsgTplMap = map[string]map[string]string{
Dingtalk: {"title": DingtalkTitle, "content": NewTplMap[Dingtalk]},
Email: {"subject": NewTplMap[EmailSubject], "content": NewTplMap[Email]},
FeishuCard: {"title": FeishuCardTitle, "content": NewTplMap[FeishuCard]},
Feishu: {"content": NewTplMap[Feishu]},
Wecom: {"content": NewTplMap[Wecom]},
Lark: {"content": NewTplMap[Lark]},
LarkCard: {"title": LarkCardTitle, "content": NewTplMap[LarkCard]},
Telegram: {"content": NewTplMap[Telegram]},
"ali-voice": {"content": NewTplMap["ali-voice"]},
"ali-sms": {"content": NewTplMap["ali-sms"]},
"tx-voice": {"content": NewTplMap["tx-voice"]},
"tx-sms": {"content": NewTplMap["tx-sms"]},
}

func InitMessageTemplate(ctx *ctx.Context) {
if !ctx.IsCenter {
return
}

for channel, content := range MsgTplMap {
msgTpl := MessageTemplate{
Name: channel,
Ident: channel,
Content: content,
NotifyChannelIdent: channel,
CreateBy: "system",
CreateAt: time.Now().Unix(),
UpdateBy: "system",
UpdateAt: time.Now().Unix(),
}

err := msgTpl.Upsert(ctx, channel)
if err != nil {
logger.Warningf("failed to upsert msg tpls %v", err)
}
}
}

func (t *MessageTemplate) Upsert(ctx *ctx.Context, ident string) error {
tpl, err := MessageTemplateGet(ctx, "ident = ?", ident)
if err != nil {
return errors.WithMessage(err, "failed to get message tpl")
}
if tpl == nil {
return Insert(ctx, t)
}

if tpl.UpdateBy != "" && tpl.UpdateBy != "system" {
return nil
}
return tpl.Update(ctx, *t)
}

func (t *MessageTemplate) RenderEvent(events []*AlertCurEvent) map[string]string {
// event 内容渲染到 messageTemplate
tplContent := make(map[string]string)
for key, msgTpl := range t.Content {
var defs = []string{
"{{ $events := . }}",
"{{ $event := index $events 0 }}",
"{{ $labels := $event.TagsMap }}",
"{{ $value := $event.TriggerValue }}",
}
text := strings.Join(append(defs, msgTpl), "")
tpl, err := template.New(key).Funcs(tplx.TemplateFuncMap).Parse(text)
if err != nil {
continue
}

var body bytes.Buffer
if err = tpl.Execute(&body, events); err != nil {
continue
}

if t.NotifyChannelIdent != "email" {
content := strings.ReplaceAll(body.String(), "\n", " \\n")
tplContent[key] = content
} else {
tplContent[key] = body.String()
}
}
return tplContent
}

+ 65
- 7
models/migrate/migrate.go View File

@@ -66,8 +66,8 @@ func MigrateTables(db *gorm.DB) error {
dts := []interface{}{&RecordingRule{}, &AlertRule{}, &AlertSubscribe{}, &AlertMute{},
&TaskRecord{}, &ChartShare{}, &Target{}, &Configs{}, &Datasource{}, &NotifyTpl{},
&Board{}, &BoardBusigroup{}, &Users{}, &SsoConfig{}, &models.BuiltinMetric{},
&models.MetricFilter{}, &models.NotificaitonRecord{}, models.UserToken{},
&models.TargetBusiGroup{}, &EsIndexPatternMigrate{}, &DashAnnotation{}}
&models.MetricFilter{}, &models.NotificaitonRecord{}, &models.TargetBusiGroup{},
&models.UserToken{}, &models.DashAnnotation{}, MessageTemplate{}, NotifyRule{}, NotifyChannelConfig{}}

if isPostgres(db) {
dts = append(dts, &models.PostgresBuiltinComponent{})
@@ -213,14 +213,18 @@ type AlertRule struct {
ExtraConfig string `gorm:"type:text;column:extra_config"`
CronPattern string `gorm:"type:varchar(64);column:cron_pattern"`
DatasourceQueries []models.DatasourceQuery `gorm:"datasource_queries;type:text;serializer:json"` // datasource queries
NotifyRuleIds []int64 `gorm:"column:notify_rule_ids;type:varchar(1024)"`
NotifyVersion int `gorm:"column:notify_version;type:int;default:0"`
}

type AlertSubscribe struct {
ExtraConfig string `gorm:"type:text;column:extra_config"` // extra config
Severities string `gorm:"column:severities;type:varchar(32);not null;default:''"`
BusiGroups ormx.JSONArr `gorm:"column:busi_groups;type:varchar(4096)"`
Note string `gorm:"column:note;type:varchar(1024);default:'';comment:note"`
RuleIds []int64 `gorm:"column:rule_ids;type:varchar(1024)"`
ExtraConfig string `gorm:"type:text;column:extra_config"` // extra config
Severities string `gorm:"column:severities;type:varchar(32);not null;default:''"`
BusiGroups ormx.JSONArr `gorm:"column:busi_groups;type:varchar(4096)"`
Note string `gorm:"column:note;type:varchar(1024);default:'';comment:note"`
RuleIds []int64 `gorm:"column:rule_ids;type:varchar(1024)"`
NotifyRuleIds []int64 `gorm:"column:notify_rule_ids;type:varchar(1024)"`
NotifyVersion int `gorm:"column:notify_version;type:int;default:0"`
}

type AlertMute struct {
@@ -346,3 +350,57 @@ type DashAnnotation struct {
func (DashAnnotation) TableName() string {
return "dash_annotation"
}

type MessageTemplate struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
Name string `gorm:"column:name;type:varchar(64);not null"`
Ident string `gorm:"column:ident;type:varchar(64);not null"`
Content map[string]string `gorm:"column:content;type:text"`
UserGroupIds []int64 `gorm:"column:user_group_ids;type:varchar(64)"`
NotifyChannelIdent string `gorm:"column:notify_channel_ident;type:varchar(64);not null;default:''"`
Private int `gorm:"column:private;type:int;not null;default:0"`
CreateAt int64 `gorm:"column:create_at;not null;default:0"`
CreateBy string `gorm:"column:create_by;type:varchar(64);not null;default:''"`
UpdateAt int64 `gorm:"column:update_at;not null;default:0"`
UpdateBy string `gorm:"column:update_by;type:varchar(64);not null;default:''"`
}

func (t *MessageTemplate) TableName() string {
return "message_template"
}

type NotifyRule struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
Name string `gorm:"column:name;type:varchar(255);not null"`
Description string `gorm:"column:description;type:text"`
Enable bool `gorm:"column:enable;not null;default:false"`
UserGroupIds []int64 `gorm:"column:user_group_ids;type:varchar(255)"`
NotifyConfigs []models.NotifyConfig `gorm:"column:notify_configs;type:text"`
CreateAt int64 `gorm:"column:create_at;not null;default:0"`
CreateBy string `gorm:"column:create_by;type:varchar(64);not null;default:''"`
UpdateAt int64 `gorm:"column:update_at;not null;default:0"`
UpdateBy string `gorm:"column:update_by;type:varchar(64);not null;default:''"`
}

func (r *NotifyRule) TableName() string {
return "notify_rule"
}

type NotifyChannelConfig struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
Name string `gorm:"column:name;type:varchar(255);not null"`
Ident string `gorm:"column:ident;type:varchar(255);not null"`
Description string `gorm:"column:description;type:text"`
Enable bool `gorm:"column:enable;not null;default:false"`
ParamConfig models.NotifyParamConfig `gorm:"column:param_config;type:text"`
RequestType string `gorm:"column:request_type;type:varchar(50);not null"`
RequestConfig *models.RequestConfig `gorm:"column:request_config;type:text"`
CreateAt int64 `gorm:"column:create_at;not null;default:0"`
CreateBy string `gorm:"column:create_by;type:varchar(64);not null;default:''"`
UpdateAt int64 `gorm:"column:update_at;not null;default:0"`
UpdateBy string `gorm:"column:update_by;type:varchar(64);not null;default:''"`
}

func (c *NotifyChannelConfig) TableName() string {
return "notify_channel"
}

+ 16
- 14
models/notification_record.go View File

@@ -12,23 +12,25 @@ const (
)

type NotificaitonRecord struct {
Id int64 `json:"id" gorm:"primaryKey;type:bigint;autoIncrement"`
EventId int64 `json:"event_id" gorm:"type:bigint;not null;index:idx_evt,priority:1;comment:event history id"`
SubId int64 `json:"sub_id" gorm:"type:bigint;comment:subscribed rule id"`
Channel string `json:"channel" gorm:"type:varchar(255);not null;comment:notification channel name"`
Status int `json:"status" gorm:"type:int;comment:notification status"` // 1-成功,2-失败
Target string `json:"target" gorm:"type:varchar(1024);not null;comment:notification target"`
Details string `json:"details" gorm:"type:varchar(2048);default:'';comment:notification other info"`
CreatedAt int64 `json:"created_at" gorm:"type:bigint;not null;comment:create time"`
Id int64 `json:"id" gorm:"primaryKey;type:bigint;autoIncrement"`
NotifyRuleID int64 `json:"notify_rule_id" gorm:"type:bigint;comment:notify rule id"`
EventId int64 `json:"event_id" gorm:"type:bigint;not null;index:idx_evt,priority:1;comment:event history id"`
SubId int64 `json:"sub_id" gorm:"type:bigint;comment:subscribed rule id"`
Channel string `json:"channel" gorm:"type:varchar(255);not null;comment:notification channel name"`
Status int `json:"status" gorm:"type:int;comment:notification status"` // 1-成功,2-失败
Target string `json:"target" gorm:"type:varchar(1024);not null;comment:notification target"`
Details string `json:"details" gorm:"type:varchar(2048);default:'';comment:notification other info"`
CreatedAt int64 `json:"created_at" gorm:"type:bigint;not null;comment:create time"`
}

func NewNotificationRecord(event *AlertCurEvent, channel, target string) *NotificaitonRecord {
func NewNotificationRecord(event *AlertCurEvent, notifyRuleID int64, channel, target string) *NotificaitonRecord {
return &NotificaitonRecord{
EventId: event.Id,
SubId: event.SubRuleId,
Channel: channel,
Status: NotiStatusSuccess,
Target: target,
NotifyRuleID: notifyRuleID,
EventId: event.Id,
SubId: event.SubRuleId,
Channel: channel,
Status: NotiStatusSuccess,
Target: target,
}
}



+ 1299
- 0
models/notify_channel.go
File diff suppressed because it is too large
View File


+ 728
- 0
models/notify_channel_test.go View File

@@ -0,0 +1,728 @@
package models

import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)

func TestSendDingTalkNotification(t *testing.T) {
// 创建一个测试服务器来模拟钉钉API响应
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 验证请求方法和内容类型
if r.Method != "POST" {
t.Errorf("Expected POST request, got %s", r.Method)
}

contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Expected Content-Type: application/json, got %s", contentType)
}

// 检查URL中的access_token参数
token := r.URL.Query().Get("access_token")
if token != "test-token" {
t.Errorf("Expected access_token=test-token, got %s", token)
}

// 读取请求体
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("Failed to read request body: %v", err)
}

// 检查请求体是否包含预期的内容
if !strings.Contains(string(body), "测试告警消息") {
t.Errorf("Request body does not contain expected content, got: %s", string(body))
}

// 返回成功响应
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"errcode":0,"errmsg":"ok"}`))
}))
defer server.Close()

// 创建钉钉通知配置
notifyChannel := &NotifyChannelConfig{
RequestType: "http",
RequestConfig: &RequestConfig{
HTTPRequestConfig: &HTTPRequestConfig{
Method: "POST",
URL: server.URL, // 使用测试服务器的URL
Timeout: 5,
Request: RequestDetail{
Body: `{"msgtype":"text","text":{"content":"{{ $tpl.content }}"},"at":{"isAtAll":false,"atMobiles":["{{ $params.ats }}"]}}`,
Parameters: map[string]string{
"access_token": "{{ $params.access_token }}",
},
},
Headers: map[string]string{
"Content-Type": "application/json",
},
RetryTimes: 2,
RetryInterval: 1,
},
},
ParamConfig: &NotifyParamConfig{
Custom: Params{
Params: []ParamItem{
{
Key: "access_token",
},
{
Key: "ats",
},
},
},
},
}

// 创建测试事件
events := []*AlertCurEvent{
{
Hash: "test-hash",
RuleName: "测试规则",
Severity: 3,
TagsMap: map[string]string{
"host": "test-host",
"app": "test-app",
},
},
}

// 创建通知模板
tpl := map[string]string{
"content": "测试告警消息",
}

// 创建通知参数
params := map[string]string{
"access_token": "test-token",
"ats": "13800138000",
}

// 创建HTTP客户端
client, err := GetHTTPClient(notifyChannel)
if err != nil {
t.Fatalf("Failed to create HTTP client: %v", err)
}

// 调用SendHTTP方法
resp, err := notifyChannel.SendHTTP(events, tpl, params, &User{Phone: "+8618021015257"}, client)
if err != nil {
t.Fatalf("SendHTTP failed: %v", err)
}

// 验证响应
if !strings.Contains(resp, "errmsg") {
t.Errorf("Response does not contain expected content, got: %s", resp)
}
}

func TestSendTencentVoiceNotification(t *testing.T) {
// 创建一个测试服务器来模拟腾讯云语音API响应
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 验证请求方法和内容类型
if r.Method != "POST" {
t.Errorf("预期POST请求,得到 %s", r.Method)
}

contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
t.Errorf("预期 Content-Type: application/json,得到 %s", contentType)
}

// 验证请求头
action := r.Header.Get("X-TC-Action")
if action != "SendTtsVoice" {
t.Errorf("预期 X-TC-Action: SendTtsVoice,得到 %s", action)
}

version := r.Header.Get("X-TC-Version")
if version != "2020-09-02" {
t.Errorf("预期 X-TC-Version: 2020-09-02,得到 %s", version)
}

region := r.Header.Get("X-TC-Region")
if region == "" {
t.Errorf("缺少 X-TC-Region 请求头")
}

// 读取请求体
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("读取请求体失败: %v", err)
}

// 解析JSON请求体
var requestBody map[string]interface{}
if err := json.Unmarshal(body, &requestBody); err != nil {
t.Errorf("解析JSON请求体失败: %v", err)
}

// 根据腾讯云API文档验证必要参数
requiredFields := []string{"TemplateId", "CalledNumber", "VoiceSdkAppid"}
for _, field := range requiredFields {
if _, exists := requestBody[field]; !exists {
t.Errorf("请求体缺少必要字段 %s", field)
}
}

// 验证手机号格式符合E.164标准
calledNumber, _ := requestBody["CalledNumber"].(string)
if calledNumber == "" || !strings.HasPrefix(calledNumber, "+") {
fmt.Println(calledNumber)
t.Errorf("CalledNumber 格式不符合E.164标准: %s", calledNumber)
}

// 验证可选参数
if templateParamSet, exists := requestBody["TemplateParamSet"].([]interface{}); exists {
// 确保模板参数是字符串数组
for i, param := range templateParamSet {
if _, ok := param.(string); !ok {
t.Errorf("TemplateParamSet[%d] 不是字符串类型", i)
}
}
}

// 验证播放次数
if playTimes, exists := requestBody["PlayTimes"].(float64); exists {
if playTimes < 1 || playTimes > 3 {
t.Errorf("PlayTimes 值 %v 超出范围(1-3)", playTimes)
}
}

// 返回成功响应
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"Response":{"RequestId":"test-request-id","SendStatus":{"CallId":"test-call-id","SessionContext":"","Code":"Ok","Message":"success"},"SessionContext":""}}`))
}))
defer server.Close()

// 创建腾讯云语音通知配置
notifyChannel := &NotifyChannelConfig{
RequestType: "http",
RequestConfig: &RequestConfig{
HTTPRequestConfig: &HTTPRequestConfig{
Method: "POST",
URL: server.URL, // 使用测试服务器的URL
Timeout: 5,
Request: RequestDetail{
Body: `{"TemplateId":"1475778","CalledNumber":"{{ $sendto }}","VoiceSdkAppid":"1400655317","TemplateParamSet":["测试"],"PlayTimes":2}`,
},
Headers: map[string]string{
"Content-Type": "application/json",
"Host": "vms.tencentcloudapi.com",
"X-TC-Action": "SendTtsVoice",
"X-TC-Version": "2020-09-02",
"X-TC-Region": "ap-beijing",
"Service": "vms",
"Secret_ID": "test-id",
"Secret_Key": "test-key",
},
RetryTimes: 2,
RetryInterval: 1,
},
},
ParamConfig: &NotifyParamConfig{
UserInfo: &UserInfo{
ContactKey: "phone",
},
},
}

// 创建测试事件
events := []*AlertCurEvent{
{
Hash: "test-hash",
RuleName: "测试规则",
Severity: 3,
TagsMap: map[string]string{
"host": "test-host",
"app": "test-app",
},
},
}

// 创建通知模板
tpl := map[string]string{
"code": "123456",
}

// 创建用户信息
userInfos := []*User{
{
Phone: "+8613788888888",
},
}

// 创建HTTP客户端
client, err := GetHTTPClient(notifyChannel)
if err != nil {
t.Fatalf("创建HTTP客户端失败: %v", err)
}

// 调用SendHTTP方法
resp, err := notifyChannel.SendHTTP(events, tpl, map[string]string{}, userInfos[0], client)
if err != nil {
t.Fatalf("SendHTTP失败: %v", err)
}

// 验证响应
if !strings.Contains(resp, "RequestId") || !strings.Contains(resp, "SendStatus") {
t.Errorf("响应不包含预期内容,得到: %s", resp)
}
}

func TestSendTencentSMSNotification(t *testing.T) {
// 创建一个测试服务器来模拟腾讯云短信API响应
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 验证请求方法和内容类型
if r.Method != "POST" {
t.Errorf("预期POST请求,得到 %s", r.Method)
}

contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
t.Errorf("预期 Content-Type: application/json,得到 %s", contentType)
}

// 验证请求头
action := r.Header.Get("X-TC-Action")
if action != "SendSms" {
t.Errorf("预期 X-TC-Action: SendSms,得到 %s", action)
}

version := r.Header.Get("X-TC-Version")
if version != "2021-01-11" {
t.Errorf("预期 X-TC-Version: 2021-01-11,得到 %s", version)
}

region := r.Header.Get("X-TC-Region")
if region != "ap-guangzhou" {
t.Errorf("预期 X-TC-Region: ap-guangzhou,得到 %s", region)
}

// 读取请求体
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("读取请求体失败: %v", err)
}

// 检查请求体是否包含预期的内容和格式
bodyStr := string(body)
if !strings.Contains(bodyStr, "PhoneNumberSet") || !strings.Contains(bodyStr, "SmsSdkAppId") {
t.Errorf("请求体不包含预期内容,得到: %s", bodyStr)
}

// 新增:验证更多腾讯云短信API必要字段
expectedFields := []string{
"TemplateId",
"SignName",
"TemplateParamSet",
}

for _, field := range expectedFields {
if !strings.Contains(bodyStr, field) {
t.Errorf("请求体缺少必要字段 %s,得到: %s", field, bodyStr)
}
}

// 新增:解析JSON并验证字段值
var requestBody map[string]interface{}
if err := json.Unmarshal(body, &requestBody); err != nil {
t.Errorf("解析请求体JSON失败: %v", err)
} else {
// 验证PhoneNumberSet正确性
phoneNumbers, ok := requestBody["PhoneNumberSet"].([]interface{})
if !ok || len(phoneNumbers) == 0 {
t.Errorf("PhoneNumberSet格式不正确或为空")
} else {
// 验证手机号格式是否符合E.164标准 (+国家码手机号)
phoneStr, _ := phoneNumbers[0].(string)
fmt.Println(phoneStr)
}

// 验证SmsSdkAppId不为空
if appId, ok := requestBody["SmsSdkAppId"].(string); !ok || appId == "" {
t.Errorf("SmsSdkAppId不存在或为空")
}

// 验证TemplateId不为空
if templateId, ok := requestBody["TemplateId"].(string); !ok || templateId == "" {
t.Errorf("TemplateId不存在或为空")
}
}

// 返回成功响应
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"Response":{"RequestId":"test-request-id","SendStatusSet":[{"SerialNo":"2023011100001111","PhoneNumber":"+8618021015257","Fee":1,"SessionContext":"","Code":"Ok","Message":"send success","IsoCode":"CN"}]}}`))
}))
defer server.Close()

// 创建腾讯云短信通知配置
notifyChannel := &NotifyChannelConfig{
RequestType: "http",
RequestConfig: &RequestConfig{
HTTPRequestConfig: &HTTPRequestConfig{
Method: "POST",
URL: server.URL, // 使用测试服务器的URL
Timeout: 5,
Request: RequestDetail{
Body: `{"PhoneNumberSet":["{{ $sendto }}"],"SignName":"测试签名","SmsSdkAppId":"1400000000","TemplateId":"1000000","TemplateParamSet":["测试"]}`,
},
Headers: map[string]string{
"Content-Type": "application/json",
"Host": "sms.tencentcloudapi.com",
"X-TC-Action": "SendSms",
"X-TC-Version": "2021-01-11",
"X-TC-Region": "ap-guangzhou",
"Service": "sms",
"Secret_ID": "test-id",
"Secret_Key": "test-key",
},
RetryTimes: 2,
RetryInterval: 1,
},
},
ParamConfig: &NotifyParamConfig{
UserInfo: &UserInfo{
ContactKey: "phone",
},
},
}

// 创建测试事件
events := []*AlertCurEvent{
{
Hash: "test-hash",
RuleName: "测试规则",
Severity: 3,
TagsMap: map[string]string{
"host": "test-host",
"app": "test-app",
},
},
}

// 创建通知模板
tpl := map[string]string{
"code": "123456",
}

// 创建用户信息
userInfos := []*User{
{
Phone: "+8618021015257",
},
{
Phone: "+8618021015258",
},
}

// 创建HTTP客户端
client, err := GetHTTPClient(notifyChannel)
if err != nil {
t.Fatalf("创建HTTP客户端失败: %v", err)
}

// 调用SendHTTP方法
resp, err := notifyChannel.SendHTTP(events, tpl, map[string]string{}, userInfos[0], client)
if err != nil {
t.Fatalf("SendHTTP失败: %v", err)
}

// 验证响应
if !strings.Contains(resp, "RequestId") || !strings.Contains(resp, "SendStatusSet") {
t.Errorf("响应不包含预期内容,得到: %s", resp)
}
}

func TestSendAliYunVoiceNotification(t *testing.T) {
data, err := readKeyValueFromJsonFile("/tmp/aliyun.json")
if err != nil {
t.Fatalf("读取JSON文件失败: %v", err)
}

// 创建阿里云语音通知配置
notifyChannel := &NotifyChannelConfig{
Ident: "ali-voice",
RequestType: "http",
RequestConfig: &RequestConfig{
HTTPRequestConfig: &HTTPRequestConfig{
Method: "POST",
URL: "http://dyvmsapi.aliyuncs.com",
Timeout: 10,
Request: RequestDetail{
Parameters: map[string]string{
"AccessKeyId": data["AccessKeyId"],
"AccessKeySecret": data["AccessKeySecret"],
"TtsCode": data["TtsCode"],
"CalledNumber": `{{ $sendto }}`,
"TtsParam": `{"alert_name":"test"}`,
},
},
RetryTimes: 2,
RetryInterval: 1,
},
},
ParamConfig: &NotifyParamConfig{
UserInfo: &UserInfo{
ContactKey: "phone",
},
},
}

// 创建测试事件
events := []*AlertCurEvent{
{
Hash: "test-hash",
RuleName: "测试规则",
Severity: 3,
TagsMap: map[string]string{
"host": "test-host",
"app": "test-app",
},
},
}

// 创建通知模板
tpl := map[string]string{
"code": "123456",
}

// 创建用户信息
user := &User{
Phone: data["Phone"],
}

// 创建HTTP客户端
client, err := GetHTTPClient(notifyChannel)
if err != nil {
t.Fatalf("创建HTTP客户端失败: %v", err)
}

// 调用SendHTTP方法
resp, err := notifyChannel.SendHTTP(events, tpl, map[string]string{}, user, client)
if err != nil {
t.Fatalf("SendHTTP失败: %v", err)
}

// 验证响应
if !strings.Contains(resp, "RequestId") || !strings.Contains(resp, "CallId") {
t.Errorf("响应不包含预期内容,得到: %s", resp)
}
}

func TestSendAliYunSMSNotification(t *testing.T) {
data, err := readKeyValueFromJsonFile("/tmp/aliyun.json")
if err != nil {
t.Fatalf("读取JSON文件失败: %v", err)
}

fmt.Println(data)

notifyChannel := &NotifyChannelConfig{
Ident: "ali-sms",
RequestType: "http",
RequestConfig: &RequestConfig{
HTTPRequestConfig: &HTTPRequestConfig{
Method: "POST",
URL: "https://dysmsapi.aliyuncs.com",
Timeout: 10000,
Request: RequestDetail{
Parameters: map[string]string{
"PhoneNumbers": "{{ $sendto }}",
"SignName": data["SignName"],
"TemplateCode": data["TemplateCode"],
"TemplateParam": `{"name":"text","tag":"text"}`,
"AccessKeyId": data["AccessKeyId"],
"AccessKeySecret": data["AccessKeySecret"],
},
},
Headers: map[string]string{
"Content-Type": "application/json",
},
RetryTimes: 2,
RetryInterval: 1,
},
},
ParamConfig: &NotifyParamConfig{
UserInfo: &UserInfo{
ContactKey: "phone",
},
},
}

// 创建测试事件
events := []*AlertCurEvent{
{
Hash: "test-hash",
RuleName: "测试规则",
Severity: 3,
TagsMap: map[string]string{
"host": "test-host",
"app": "test-app",
},
},
}

// 创建通知模板
tpl := map[string]string{
"code": "123456",
}

// 创建用户信息
user := &User{
Phone: data["Phone"],
}

// 创建HTTP客户端
client, err := GetHTTPClient(notifyChannel)
if err != nil {
t.Fatalf("创建HTTP客户端失败: %v", err)
}

// 调用SendHTTP方法
resp, err := notifyChannel.SendHTTP(events, tpl, map[string]string{}, user, client)
if err != nil {
t.Fatalf("SendHTTP失败: %v", err)
}

// 验证响应
if !strings.Contains(resp, "BizId") || !strings.Contains(resp, "RequestId") {
t.Errorf("响应不包含预期内容,得到: %s", resp)
}
}

func TestSendFlashDuty(t *testing.T) {
// 创建一个模拟HTTP服务器
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 验证请求方法
if r.Method != "POST" {
t.Errorf("预期POST请求,得到 %s", r.Method)
}

// 验证请求URL参数
channelID := r.URL.Query().Get("channel_id")
if channelID != "4344322009498" {
t.Errorf("预期channel_id=4344322009498,得到 %s", channelID)
}

// 读取并验证请求体
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("读取请求体失败: %v", err)
}

// 尝试解析事件数据
var events []*AlertCurEvent
err = json.Unmarshal(body, &events)
if err != nil {
t.Errorf("解析事件数据失败: %v", err)
}

if len(events) == 0 {
t.Errorf("请求体不包含事件数据")
} else if events[0].RuleName != "测试告警规则" {
t.Errorf("事件规则名称不匹配,预期'测试告警规则',得到'%s'", events[0].RuleName)
}

// 返回成功响应
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"code": 200, "message": "success", "data": {"id": "123456"}}`))
}))
defer server.Close()

// 创建NotifyChannelConfig对象
notifyChannel := &NotifyChannelConfig{
ID: 1,
Name: "FlashDuty测试",
Ident: "flashduty-test",
RequestType: "flashduty",
RequestConfig: &RequestConfig{
FlashDutyRequestConfig: &FlashDutyRequestConfig{
IntegrationUrl: server.URL,
},
},
}

// 创建测试事件
events := []*AlertCurEvent{
{
Hash: "test-hash-123",
RuleId: 123,
RuleName: "测试告警规则",
Severity: 2,
GroupId: 1,
GroupName: "测试团队",
TriggerTime: time.Now().Unix(),
TriggerValue: "90.5",
LastEvalTime: time.Now().Unix(),
Status: 1,
TagsMap: map[string]string{
"host": "test-host",
"service": "test-service",
},
},
}

// 创建HTTP客户端
client := &http.Client{
Timeout: 5 * time.Second,
}

// 调用SendFlashDuty方法
flashDutyChannelID := int64(4344322009498)
resp, err := notifyChannel.SendFlashDuty(events, flashDutyChannelID, client)

// 验证结果
if err != nil {
t.Errorf("SendFlashDuty返回错误: %v", err)
}

// 验证响应内容
if !strings.Contains(resp, "success") {
t.Errorf("响应内容不包含预期的'success'字符串, 得到: %s", resp)
}

// 测试无效的客户端情况
_, err = notifyChannel.SendFlashDuty(events, flashDutyChannelID, nil)
if err == nil || !strings.Contains(err.Error(), "http client not found") {
t.Errorf("预期错误'http client not found',但得到: %v", err)
}

// 测试请求失败的情况
invalidNotifyChannel := &NotifyChannelConfig{
RequestType: "flashduty",
RequestConfig: &RequestConfig{
FlashDutyRequestConfig: &FlashDutyRequestConfig{
IntegrationUrl: "http://invalid-url-that-does-not-exist",
},
},
}

_, err = invalidNotifyChannel.SendFlashDuty(events, flashDutyChannelID, client)
if err == nil {
t.Errorf("预期请求失败,但未返回错误")
}
}

// read key value from json file
func readKeyValueFromJsonFile(filePath string) (map[string]string, error) {
jsonFile, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer jsonFile.Close()

var data map[string]string
err = json.NewDecoder(jsonFile).Decode(&data)
return data, err
}

+ 241
- 0
models/notify_rule.go View File

@@ -0,0 +1,241 @@
package models

import (
"errors"
"time"

"github.com/ccfos/nightingale/v6/pkg/ctx"
"github.com/ccfos/nightingale/v6/pkg/poster"
)

type NotifyRule struct {
ID int64 `json:"id" gorm:"primarykey"`
Name string `json:"name"` // 名称
Description string `json:"description"` // 备注
Enable bool `json:"enable"` // 启用状态
UserGroupIds []int64 `json:"user_group_ids" gorm:"serializer:json"` // 告警组ID

// 通知配置
NotifyConfigs []NotifyConfig `json:"notify_configs" gorm:"serializer:json"`

CreateAt int64 `json:"create_at"`
CreateBy string `json:"create_by"`
UpdateAt int64 `json:"update_at"`
UpdateBy string `json:"update_by"`
}

func (r *NotifyRule) TableName() string {
return "notify_rule"
}

type NotifyConfig struct {
ChannelID int64 `json:"channel_id"` // 通知媒介(如:阿里云短信)
TemplateID int64 `json:"template_id"` // 通知模板
Params map[string]interface{} `json:"params"` // 通知参数

Severities []int `json:"severities"` // 适用级别(一级告警、二级告警、三级告警)
TimeRanges []TimeRanges `json:"time_ranges"` // 适用时段
LabelKeys []TagFilter `json:"label_keys"` // 适用标签
Attributes []TagFilter `json:"attributes"` // 适用属性
}

type CustomParams struct {
UserIDs []int64 `json:"user_ids"`
UserGroupIDs []int64 `json:"user_group_ids"`
IDs []int64 `json:"ids"`
}

type TimeRanges struct {
Start string `json:"start"`
End string `json:"end"`
Week []int `json:"week"`
}

var NotifyRuleCache struct {
}

// 创建 NotifyRule
func CreateNotifyRule(c *ctx.Context, rule *NotifyRule) error {
return DB(c).Create(rule).Error
}

// 读取 NotifyRule
func GetNotifyRule(c *ctx.Context, id int64) (*NotifyRule, error) {
var rule NotifyRule
if err := DB(c).First(&rule, id).Error; err != nil {
return nil, err
}
return &rule, nil
}

// 更新 NotifyRule
func UpdateNotifyRule(c *ctx.Context, rule *NotifyRule) error {
return DB(c).Save(rule).Error
}

// 删除 NotifyRule
func DeleteNotifyRule(c *ctx.Context, id int64) error {
return DB(c).Delete(&NotifyRule{}, id).Error
}

func NotifyRuleStatistics(ctx *ctx.Context) (*Statistics, error) {
if !ctx.IsCenter {
s, err := poster.GetByUrls[*Statistics](ctx, "/v1/n9e/statistic?name=notify_rule")
return s, err
}

session := DB(ctx).Model(&NotifyRule{}).Select("count(*) as total", "max(update_at) as last_updated").Where("enable = ?", true)

var stats []*Statistics
err := session.Find(&stats).Error
if err != nil {
return nil, err
}

return stats[0], nil
}

func NotifyRuleGetsAll(ctx *ctx.Context) ([]*NotifyRule, error) {
if !ctx.IsCenter {
rules, err := poster.GetByUrls[[]*NotifyRule](ctx, "/v1/n9e/notify-rules")
return rules, err
}

var rules []*NotifyRule
err := DB(ctx).Where("enable = ?", true).Find(&rules).Error
if err != nil {
return nil, err
}

return rules, nil
}

func (r *NotifyRule) Verify() error {
if r.Name == "" {
return errors.New("name cannot be empty")
}

// if len(r.UserGroupIds) == 0 {
// return errors.New("user group ids cannot be empty")
// }

// if len(r.NotifyConfigs) == 0 {
// return errors.New("notify configs cannot be empty")
// }

for _, config := range r.NotifyConfigs {
if err := config.Verify(); err != nil {
return err
}
}

return nil
}

func (c *NotifyConfig) Verify() error {
if c.ChannelID <= 0 {
return errors.New("invalid channel id")
}

if len(c.Severities) == 0 {
return errors.New("severities cannot be empty")
}
for _, severity := range c.Severities {
if severity < 1 || severity > 3 {
return errors.New("invalid severity level")
}
}

for _, timeRange := range c.TimeRanges {
if err := timeRange.Verify(); err != nil {
return err
}
}

for _, label := range c.LabelKeys {
if err := label.Verify(); err != nil {
return err
}
}

return nil
}

func (t *TimeRanges) Verify() error {
if t.Start == "" {
return errors.New("start time cannot be empty")
}
if t.End == "" {
return errors.New("end time cannot be empty")
}

// 进一步校验时间格式或检查时间段的合理性

return nil
}

func (r *NotifyRule) Update(ctx *ctx.Context, ref NotifyRule) error {
// ref.FE2DB()

ref.ID = r.ID
ref.CreateAt = r.CreateAt
ref.CreateBy = r.CreateBy
ref.UpdateAt = time.Now().Unix()

err := ref.Verify()
if err != nil {
return err
}
return DB(ctx).Model(r).Select("*").Updates(ref).Error
}

func (r *NotifyRule) DB2FE() {
if r.UserGroupIds == nil {
r.UserGroupIds = make([]int64, 0)
}
if r.NotifyConfigs == nil {
r.NotifyConfigs = make([]NotifyConfig, 0)
}
}

func NotifyRuleGet(ctx *ctx.Context, where string, args ...interface{}) (*NotifyRule, error) {
lst, err := NotifyRulesGet(ctx, where, args...)
if err != nil || len(lst) == 0 {
return nil, err
}
return lst[0], err
}

func NotifyRulesGet(ctx *ctx.Context, where string, args ...interface{}) ([]*NotifyRule, error) {
lst := make([]*NotifyRule, 0)
session := DB(ctx)
if where != "" && len(args) > 0 {
session = session.Where(where, args...)
}
err := session.Find(&lst).Error
if err != nil {
return nil, err
}
for _, r := range lst {
r.DB2FE()
}
return lst, nil
}

type NotifyRuleChecker interface {
IfUsed(*NotifyRule) bool
}

func UsedByNotifyRule(ctx *ctx.Context, nrc NotifyRuleChecker) ([]int64, error) {
notifyRules, err := NotifyRulesGet(ctx, "", nil)
if err != nil {
return nil, err
}
ids := make([]int64, 0)
for _, nr := range notifyRules {
if nrc.IfUsed(nr) {
ids = append(ids, nr.ID)
}
}
return ids, nil
}

+ 0
- 51
models/notify_tpl.go View File

@@ -162,56 +162,6 @@ func InitNotifyConfig(c *ctx.Context, tplDir string) {
}
}

// init notify contact
cval, err = ConfigsGet(c, NOTIFYCONTACT)
if err != nil {
logger.Errorf("failed to get notify contact config: %v", err)
return
}

if cval == "" {
var notifyContacts []NotifyContact
for _, contact := range DefaultContacts {
notifyContacts = append(notifyContacts, NotifyContact{Ident: contact, Name: contact, BuiltIn: true})
}

data, _ := json.Marshal(notifyContacts)
err = ConfigsSet(c, NOTIFYCONTACT, string(data))
if err != nil {
logger.Errorf("failed to set notify contact config: %v", err)
return
}
} else {
var contacts []NotifyContact
if err = json.Unmarshal([]byte(cval), &contacts); err != nil {
logger.Errorf("failed to unmarshal notify channel config: %v", err)
return
}
contactMap := make(map[string]struct{})
for _, contact := range contacts {
contactMap[contact.Ident] = struct{}{}
}

var newContacts []NotifyContact
for _, contact := range DefaultContacts {
if _, ok := contactMap[contact]; !ok {
newContacts = append(newContacts, NotifyContact{Ident: contact, Name: contact, BuiltIn: true})
}
}
if len(newContacts) > 0 {
contacts = append(contacts, newContacts...)
data, err := json.Marshal(contacts)
if err != nil {
logger.Errorf("failed to marshal contacts: %v", err)
return
}
if err = ConfigsSet(c, NOTIFYCONTACT, string(data)); err != nil {
logger.Errorf("failed to set notify contact config: %v", err)
return
}
}
}

// init notify tpl
tplMap := getNotifyTpl(tplDir)
for channel, content := range tplMap {
@@ -258,7 +208,6 @@ func getNotifyTpl(tplDir string) map[string]string {

var TplMap = map[string]string{
Dingtalk: `#### {{if .IsRecovered}}<font color="#008800">💚{{.RuleName}}</font>{{else}}<font color="#FF0000">💔{{.RuleName}}</font>{{end}}

---
{{$time_duration := sub now.Unix .FirstTriggerTime }}{{if .IsRecovered}}{{$time_duration = sub .LastEvalTime .FirstTriggerTime }}{{end}}
- **告警级别**: {{.Severity}}级


+ 52
- 4
pkg/i18nx/var.go View File

@@ -136,7 +136,19 @@ var I18N = `{
"View Server Information": "查看告警引擎列表",
"View SSO Configuration": "单点登录管理",
"View Migration Configuration": "查看迁移配置",
"View Site Settings": "查看站点设置"
"View Site Settings": "查看站点设置",
"View Message Templates": "查看消息模板",
"Add Message Templates": "添加消息模板",
"Modify Message Templates": "修改消息模板",
"Delete Message Templates": "删除消息模板",
"View Notify Rules": "查看通知规则",
"Add Notify Rules": "添加通知规则",
"Modify Notify Rules": "修改通知规则",
"Delete Notify Rules": "删除通知规则",
"View Notify Channels": "查看通知媒介",
"Add Notify Channels": "添加通知媒介",
"Modify Notify Channels": "修改通知媒介",
"Delete Notify Channels": "删除通知媒介"
},
"zh_CN": {
"Username or password invalid": "用户名或密码错误",
@@ -273,7 +285,19 @@ var I18N = `{
"View Server Information": "查看告警引擎列表",
"View SSO Configuration": "单点登录管理",
"View Migration Configuration": "查看迁移配置",
"View Site Settings": "查看站点设置"
"View Site Settings": "查看站点设置",
"View Message Templates": "查看消息模板",
"Add Message Templates": "添加消息模板",
"Modify Message Templates": "修改消息模板",
"Delete Message Templates": "删除消息模板",
"View Notify Rules": "查看通知规则",
"Add Notify Rules": "添加通知规则",
"Modify Notify Rules": "修改通知规则",
"Delete Notify Rules": "删除通知规则",
"View Notify Channels": "查看通知媒介",
"Add Notify Channels": "添加通知媒介",
"Modify Notify Channels": "修改通知媒介",
"Delete Notify Channels": "删除通知媒介"
},
"zh_HK": {
"Username or password invalid": "用戶名或密碼錯誤",
@@ -422,7 +446,19 @@ var I18N = `{
"View Server Information": "查看告警引擎列表",
"View SSO Configuration": "單點登錄管理",
"View Migration Configuration": "查看遷移配置",
"View Site Settings": "查看站點設置"
"View Site Settings": "查看站點設置",
"View Message Templates": "查看訊息範本",
"Add Message Templates": "新增訊息範本",
"Modify Message Templates": "修改訊息範本",
"Delete Message Templates": "刪除訊息範本",
"View Notify Rules": "查看通知規則",
"Add Notify Rules": "新增通知規則",
"Modify Notify Rules": "修改通知規則",
"Delete Notify Rules": "刪除通知規則",
"View Notify Channels": "查看通知媒介",
"Add Notify Channels": "新增通知媒介",
"Modify Notify Channels": "修改通知媒介",
"Delete Notify Channels": "刪除通知媒介"
},
"ja_JP": {
"Username or password invalid": "ユーザー名またはパスワードが無効です",
@@ -560,6 +596,18 @@ var I18N = `{
"View Server Information": "アラートエンジン一覧",
"View SSO Configuration": "シングルサインオン管理",
"View Migration Configuration": "移行設定の表示",
"View Site Settings": "サイト設定の表示"
"View Site Settings": "サイト設定の表示",
"View Message Templates": "メッセージテンプレートを表示",
"Add Message Templates": "メッセージテンプレートを追加する",
"Modify Message Templates": "メッセージテンプレートを変更する",
"Delete Message Templates": "メッセージテンプレートの削除",
"View Notify Rules": "通知ルールを表示",
"Add Notify Rules": "通知ルールを追加する",
"Modify Notify Rules": "通知ルールを変更する",
"Delete Notify Rules": "通知ルールの削除",
"View Notify Channels": "通知媒体を表示",
"Add Notify Channels": "通知媒体を追加",
"Modify Notify Channels": "通知媒体を変更する",
"Delete Notify Channels": "通知媒体を削除する"
}
}`

+ 15
- 0
pkg/slice/contains.go View File

@@ -0,0 +1,15 @@
package slice

func HaveIntersection[T comparable](slice1, slice2 []T) bool {
elemMap := make(map[T]bool)
for _, val := range slice1 {
elemMap[val] = true
}

for _, val := range slice2 {
if elemMap[val] {
return true
}
}
return false
}

+ 8
- 0
pkg/str/verify.go View File

@@ -0,0 +1,8 @@
package str

import "regexp"

func IsValidURL(url string) bool {
re := regexp.MustCompile(`^https?://[^\s/$.?#].[^\s]*$`)
return re.MatchString(url)
}

+ 48
- 0
pkg/tplx/fns.go View File

@@ -1,6 +1,7 @@
package tplx

import (
"encoding/json"
"errors"
"fmt"
"html/template"
@@ -583,3 +584,50 @@ func Query(datasourceID int64, promql string) model.Value {

return nil
}

type DingTalkAtBody struct {
IsAtAll bool `json:"isAtAll"`
AtMobiles []string `json:"atMobiles"`
AtUserIds []string `json:"atUserIds"`
}

func DingTalkAt(all bool, phones string, userIDs string) template.HTML {
dt := DingTalkAtBody{
IsAtAll: all,
AtMobiles: mySplit(phones),
AtUserIds: mySplit(userIDs),
}
bytes, _ := json.Marshal(dt)
return template.HTML(bytes)
}

func mySplit(s string) []string {
strs := strings.FieldsFunc(s, func(r rune) bool {
return r == ',' || r == ';'
})
m := make(map[string]struct{})
for _, str := range strs {
m[strings.TrimSpace(str)] = struct{}{}
}
var res []string
for k := range m {
res = append(res, k)
}
return res
}

func AddPrefix(s, prefix string) string {
return prefix + s
}

func AddSuffix(s, suffix string) string {
return s + suffix
}

func ManipulateStr(str, split, prefix, suffix, join string) string {
strs := strings.Split(str, split)
for i, s := range strs {
strs[i] = prefix + s + suffix
}
return strings.Join(strs, join)
}

+ 25
- 0
pkg/tplx/tpl_test.go View File

@@ -0,0 +1,25 @@
package tplx

import (
"html/template"
"os"
"testing"
)

func TestRange(t *testing.T) {
str := "1234,2234,3234,4234"

// ",", "@", "", "-",

tmpl := `{{ manipulateStr . "," "@" "" "-" }}`

tpl, err := template.New("example").Funcs(TemplateFuncMap).Parse(tmpl)
if err != nil {
panic(err)
}

err = tpl.Execute(os.Stdout, str)
if err != nil {
panic(err)
}
}

+ 7
- 0
pkg/tplx/tplx.go View File

@@ -52,6 +52,13 @@ var TemplateFuncMap = template.FuncMap{
"externalURL": ExternalURL,
"parseDuration": ParseDuration,
"printf": Printf,
"split": strings.Split,
"join": strings.Join,
"addPrefix": AddPrefix,
"addSuffix": AddSuffix,
"manipulateStr": ManipulateStr,

"dingTalkAt": DingTalkAt,
}

// NewTemplateFuncMap copy on write for TemplateFuncMap


Loading…
Cancel
Save