Co-authored-by: flashbo <36443248+lwb0214@users.noreply.github.com> Co-authored-by: Xu Bin <140785332+Reditiny@users.noreply.github.com>main
| @@ -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) | |||
| @@ -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(¬ifyRule.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, ¬ifyRule.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 | |||
| @@ -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{}{} | |||
| @@ -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()) | |||
| @@ -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++ | |||
| @@ -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 | |||
| } | |||
| @@ -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 { | |||
| @@ -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 | |||
| @@ -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 | |||
| } | |||
| @@ -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 | |||
| ` | |||
| ) | |||
| @@ -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) | |||
| @@ -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) { | |||
| @@ -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)) | |||
| } | |||
| } | |||
| } | |||
| @@ -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") | |||
| } | |||
| @@ -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", ¬ifyChannelIdents).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) | |||
| } | |||
| @@ -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} | |||
| @@ -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 | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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) | |||
| @@ -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 | |||
| @@ -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= | |||
| @@ -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 | |||
| } | |||
| @@ -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{}{} | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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() | |||
| @@ -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, | |||
| } | |||
| } | |||
| @@ -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 | |||
| @@ -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) | |||
| @@ -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:"-"` | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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" | |||
| } | |||
| @@ -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, | |||
| } | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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}}级 | |||
| @@ -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": "通知媒体を削除する" | |||
| } | |||
| }` | |||
| @@ -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 | |||
| } | |||
| @@ -0,0 +1,8 @@ | |||
| package str | |||
| import "regexp" | |||
| func IsValidURL(url string) bool { | |||
| re := regexp.MustCompile(`^https?://[^\s/$.?#].[^\s]*$`) | |||
| return re.MatchString(url) | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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) | |||
| } | |||
| } | |||
| @@ -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 | |||