Add the Loki query UI, access policies, ingress insights, alert and registry settings, and their backend APIs. Include the related Kubernetes deployment and collector updates required by the integrated application. Validated with go test ./..., npm run build, and npm run lint.
227 lines
5.8 KiB
Go
227 lines
5.8 KiB
Go
package services
|
|
|
|
// 多通知通道(飞书/Lark 群机器人)CRUD 与按通道发送。
|
|
// 取代旧的单 webhook 全局配置:每个告警群一个通道,告警规则各自关联。
|
|
import (
|
|
"bytes"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"opsxloki/models"
|
|
"opsxloki/pkg/crypto"
|
|
"opsxloki/repository"
|
|
)
|
|
|
|
const channelHTTPTimeout = 10 * time.Second
|
|
|
|
func ListNotifyChannels() []map[string]interface{} {
|
|
db := repository.GetDB()
|
|
db.RLock()
|
|
out := make([]map[string]interface{}, 0, len(db.NotifyChannels))
|
|
for i := range db.NotifyChannels {
|
|
out = append(out, db.NotifyChannels[i].Sanitized())
|
|
}
|
|
db.RUnlock()
|
|
return out
|
|
}
|
|
|
|
func GetNotifyChannel(id uint) *models.NotifyChannel {
|
|
db := repository.GetDB()
|
|
db.RLock()
|
|
defer db.RUnlock()
|
|
for i := range db.NotifyChannels {
|
|
if db.NotifyChannels[i].ID == id {
|
|
c := db.NotifyChannels[i]
|
|
return &c
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetNotifyChannelDecrypted 取通道并解密凭证(发送用)
|
|
func GetNotifyChannelDecrypted(id uint) *models.NotifyChannel {
|
|
c := GetNotifyChannel(id)
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
if w, err := DecryptCred(c.Webhook); err == nil {
|
|
c.Webhook = w
|
|
}
|
|
if s, err := DecryptCred(c.SigningSecret); err == nil {
|
|
c.SigningSecret = s
|
|
}
|
|
return c
|
|
}
|
|
|
|
// SaveNotifyChannel 新建(id=0)或更新;webhook/secret 非空才更新(留空=保留旧值)
|
|
func SaveNotifyChannel(id uint, name, webhook, secret string, enabled bool) (*models.NotifyChannel, error) {
|
|
db := repository.GetDB()
|
|
// 更新:留空字段回填旧值(旧值已是密文)
|
|
if id > 0 {
|
|
db.RLock()
|
|
var old models.NotifyChannel
|
|
found := false
|
|
for i := range db.NotifyChannels {
|
|
if db.NotifyChannels[i].ID == id {
|
|
old = db.NotifyChannels[i]
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
db.RUnlock()
|
|
if !found {
|
|
return nil, fmt.Errorf("通道不存在")
|
|
}
|
|
if webhook == "" {
|
|
webhook = old.Webhook
|
|
} else if enc, err := crypto.EncryptField(webhook); err == nil {
|
|
webhook = enc
|
|
}
|
|
if secret == "" {
|
|
secret = old.SigningSecret
|
|
} else if enc, err := crypto.EncryptField(secret); err == nil {
|
|
secret = enc
|
|
}
|
|
db.Lock()
|
|
defer db.Unlock()
|
|
for i := range db.NotifyChannels {
|
|
if db.NotifyChannels[i].ID == id {
|
|
db.NotifyChannels[i].Name = name
|
|
db.NotifyChannels[i].Webhook = webhook
|
|
db.NotifyChannels[i].SigningSecret = secret
|
|
db.NotifyChannels[i].Enabled = enabled
|
|
db.NotifyChannels[i].UpdatedAt = time.Now()
|
|
db.RequestSave()
|
|
c := db.NotifyChannels[i]
|
|
return &c, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("通道不存在")
|
|
}
|
|
|
|
// 新建
|
|
if name == "" {
|
|
return nil, fmt.Errorf("通道名称不能为空")
|
|
}
|
|
if webhook == "" {
|
|
return nil, fmt.Errorf("webhook 不能为空")
|
|
}
|
|
if enc, err := crypto.EncryptField(webhook); err == nil {
|
|
webhook = enc
|
|
}
|
|
if secret != "" {
|
|
if enc, err := crypto.EncryptField(secret); err == nil {
|
|
secret = enc
|
|
}
|
|
}
|
|
newID := db.NextID("notifyChannels")
|
|
ch := &models.NotifyChannel{
|
|
ID: newID, Name: name, Webhook: webhook, SigningSecret: secret, Enabled: enabled,
|
|
}
|
|
db.Lock()
|
|
defer db.Unlock()
|
|
db.NotifyChannels = append(db.NotifyChannels, *ch)
|
|
db.RequestSave()
|
|
return ch, nil
|
|
}
|
|
|
|
func DeleteNotifyChannel(id uint) error {
|
|
// 有关联规则时拒绝删除
|
|
db := repository.GetDB()
|
|
db.RLock()
|
|
for i := range db.AlertRules {
|
|
for _, chID := range db.AlertRules[i].ChannelIDs {
|
|
if chID == id {
|
|
db.RUnlock()
|
|
return fmt.Errorf("仍有告警规则关联此通道,请先解除关联")
|
|
}
|
|
}
|
|
}
|
|
db.RUnlock()
|
|
db.Lock()
|
|
defer db.Unlock()
|
|
for i := range db.NotifyChannels {
|
|
if db.NotifyChannels[i].ID == id {
|
|
db.NotifyChannels = append(db.NotifyChannels[:i], db.NotifyChannels[i+1:]...)
|
|
db.RequestSave()
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("通道不存在")
|
|
}
|
|
|
|
// larkSignChannel 飞书签名
|
|
func larkSignChannel(secret string, ts int64) string {
|
|
stringToSign := fmt.Sprintf("%d\n%s", ts, secret)
|
|
h := hmac.New(sha256.New, []byte(stringToSign))
|
|
return base64.StdEncoding.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
// SendLarkCardToChannel 向指定通道发送卡片
|
|
func SendLarkCardToChannel(channelID uint, title, color string, lines []string) error {
|
|
ch := GetNotifyChannelDecrypted(channelID)
|
|
if ch == nil {
|
|
return fmt.Errorf("通知通道不存在")
|
|
}
|
|
if !ch.Enabled {
|
|
return fmt.Errorf("通道「%s」已禁用", ch.Name)
|
|
}
|
|
content := joinNonEmpty(lines, "\n")
|
|
payload := map[string]interface{}{
|
|
"msg_type": "interactive",
|
|
"card": map[string]interface{}{
|
|
"config": map[string]interface{}{"wide_screen_mode": true},
|
|
"header": map[string]interface{}{
|
|
"title": map[string]string{"tag": "plain_text", "content": title},
|
|
"template": color,
|
|
},
|
|
"elements": []interface{}{
|
|
map[string]interface{}{"tag": "div", "text": map[string]string{"tag": "lark_md", "content": content}},
|
|
map[string]interface{}{"tag": "note", "elements": []interface{}{map[string]string{"tag": "plain_text", "content": "opsx-loki 日志告警"}}},
|
|
},
|
|
},
|
|
}
|
|
if ch.SigningSecret != "" {
|
|
ts := time.Now().Unix()
|
|
payload["timestamp"] = fmt.Sprintf("%d", ts)
|
|
payload["sign"] = larkSignChannel(ch.SigningSecret, ts)
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
resp, err := (&http.Client{Timeout: channelHTTPTimeout}).Post(ch.Webhook, "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
return fmt.Errorf("飞书返回 HTTP %d", resp.StatusCode)
|
|
}
|
|
var out struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
}
|
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
|
if out.Code != 0 {
|
|
return fmt.Errorf("飞书拒绝: %s", out.Msg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func joinNonEmpty(items []string, sep string) string {
|
|
result := ""
|
|
for _, item := range items {
|
|
if item == "" {
|
|
continue
|
|
}
|
|
if result != "" {
|
|
result += sep
|
|
}
|
|
result += item
|
|
}
|
|
return result
|
|
}
|