Files
opsx 37e2f0febc feat(analysis,alerts): 每日量落库看历史趋势、趋势图/饼图可读性、告警三分页
=== 1. 每日日志量必须落库(核心问题:Loki 只留 24 小时)===
实测:请求"近 7 天每日量",Loki 只返回 27 个**小时**桶,全部在最近 24h 内 ——
因为 retention_period=24h,更早的 chunk 已经被删了。
也就是说「固定近 7 天」这张卡片,过去一直是"6 天空白 + 1 天有数据",
用户看着像坏了,实际是数据物理不存在。

方案:平台自己留存历史。
  - models.DailyVolumeSnapshot{Date(本地YYYY-MM-DD), SourceID, Bytes, Entries, Partial}
  - services/daily_volume.go
      CaptureDailyVolume        按天抓 index/stats 落库(幂等覆盖)
      StartDailyVolumeCapture   启动 20s 后回补 + 每 30 分钟检查一次
      GetDailyVolumeHistory     读库返回最近 N 天(今天会实时补抓)
  - 保留 400 天(够看年度趋势),按 source+date 去重
  - 前端可选 7 / 30 / 90 / 365 天,能看多长取决于平台积累多久

为什么用"每 30 分钟检查昨天有没有落库"而不是固定 00:10 定时:
平台可能整晚没运行,固定时点的定时器会直接错过;检查式回补不依赖平台在线时刻。

Partial 标记(关键,否则会误读):
  - 当天未结束 → partial=true
  - 待抓取日期的起点已超出 24h 保留期 → partial=true(Loki 里就没那么多数据)
实测:10-27 32.5GB/2.65亿条 partial=true(保留期外),
      10-28 348GB/28.56亿条 partial=true(当天进行中)

=== 2. 修一个 Go 副本 bug(自己写的,靠实测发现)===
第一版用 `for _, snap := range db.DailyVolumeSnapshots` 后改 snap 字段,
range 拿到的是**副本**,改动不会写回切片 → 同一天永远保持首次抓取的值,
partial 也永不更新(实测发现 10-27 该是 true 却显示 false)。
改为 `for i := range ... { snap := &db.DailyVolumeSnapshots[i] }`。

=== 3. 趋势图可读性 ===
从"裸折线"换成"渐变面积图",并在图上方给出峰值 / 均值:
  - 面积图能一眼看出"这一段整体比上一段高",折线只能靠对齐格子数
  - 峰值/均值避免用户自己对刻度
  - Y 轴与 tooltip 统一 formatBytes,"每桶体积"写进 subtitle
顺带确认:趋势图只画体积(字节),不再混入 error(条数)——单位不同不能同轴。

=== 4. 饼图:命名空间分布 → 应用日志量分布 ===
本环境的命名空间只有 astralx / log 两个,饼图没有信息量;
改成按应用看谁在刷日志(前 8 名 + 其他),才是运维真正要的答案。

=== 5. 告警页拆成三个 Tab ===
「告警规则 / 通知通道 / 发送历史」独立成分页(用 ui/tabs)。
之前通道是折叠块、历史堆在规则下面,规则一多就要滚很久才看得到。
发送历史增加空状态文案,不再是"空页面不知道有没有在发"。

=== 6. 内置规则关联默认通道 + 首次部署 webhook 留空的评估 ===
结论:**webhook 可以留空,但通道必须是禁用状态**。
  - 若种子给的是"已启用 + 空地址"通道,规则一命中就必然投递失败,
    发送历史堆满红色失败,反而让人以为平台坏了
  - 所以首次部署建一个**禁用**的占位通道「默认通知通道(待配置 Webhook)」,
    内置规则全部挂到它;管理员填好 Webhook 再启用即可,不用逐条关联
  - 保存时加校验:启用则必须有非空 Webhook(返回明确原因,不是裸 500)
  - schemaVersion v5→v6:把已有的"无通道"规则补挂到默认通道
  实测:8 条规则全部已关联通道

=== 实测(v11-1028-2252 / 187062644c0f)===
  /logs/daily-volume?days=7  → 2 天数据,partial 标记正确
  规则 8 条全部有 channelIds
  平台 ready、健康 ok
2026-10-28 22:54:12 +00:00

235 lines
6.2 KiB
Go

package services
// 多通知通道(飞书/Lark 群机器人)CRUD 与按通道发送。
// 取代旧的单 webhook 全局配置:每个告警群一个通道,告警规则各自关联。
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"lokistack/models"
"lokistack/pkg/crypto"
"lokistack/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) {
// 启用则必须有 Webhook:否则告警命中后必然投递失败,
// 用户只会看到发送历史里一片红,却不知道是配置没填完。
if enabled && strings.TrimSpace(webhook) == "" {
if existing := GetNotifyChannelDecrypted(id); existing == nil || strings.TrimSpace(existing.Webhook) == "" {
return nil, fmt.Errorf("启用通知通道前必须填写 Webhook 地址")
}
}
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": "LokiStack 日志告警"}}},
},
},
}
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
}