=== 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
235 lines
6.2 KiB
Go
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
|
|
}
|