=== 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
244 lines
6.9 KiB
Go
244 lines
6.9 KiB
Go
package services
|
||
|
||
import (
|
||
"context"
|
||
"log"
|
||
"sort"
|
||
"time"
|
||
|
||
"lokistack/models"
|
||
"lokistack/repository"
|
||
)
|
||
|
||
// DailyVolumePoint 返回给前端的单日数据点
|
||
type DailyVolumePoint struct {
|
||
Date string `json:"date"`
|
||
Bytes int64 `json:"bytes"`
|
||
Entries int64 `json:"entries"`
|
||
// Partial 当天还没过完(或抓取时数据不全),前端用虚线/浅色区分
|
||
Partial bool `json:"partial"`
|
||
// FromHistory true=来自平台留存历史;false=刚从 Loki 实时查的
|
||
FromHistory bool `json:"fromHistory"`
|
||
}
|
||
|
||
const (
|
||
// dailyVolumeBackfillDays 启动时回补天数。
|
||
// 取 2 天:昨天(完整)+ 今天(进行中),足够覆盖"重启导致漏抓"的常见情况。
|
||
// 不设更大是因为 Loki retention 通常只有 24h,更早的已经查不到了。
|
||
dailyVolumeBackfillDays = 2
|
||
// dailyVolumeRetentionDays 平台自己留多久的历史。一年足够看年度趋势。
|
||
dailyVolumeRetentionDays = 400
|
||
)
|
||
|
||
// dateKey 返回本地时区的 YYYY-MM-DD。
|
||
func dateKey(t time.Time) string {
|
||
return t.In(time.Local).Format("2006-01-02")
|
||
}
|
||
|
||
// dayRange 返回某天本地时区的 [00:00, 次日 00:00) 纳秒区间。
|
||
func dayRange(day time.Time) (int64, int64) {
|
||
local := day.In(time.Local)
|
||
start := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.Local)
|
||
return start.UnixNano(), start.AddDate(0, 0, 1).UnixNano()
|
||
}
|
||
|
||
// CaptureDailyVolume 抓取指定日期的日志量并落库(按天幂等:同一天重复抓会覆盖)。
|
||
//
|
||
// 数据来源是 Loki 的 index/stats(只读索引元数据),实测毫秒级,
|
||
// 所以就算是补几十天历史也不会拖慢任何东西。
|
||
func CaptureDailyVolume(source *models.LogSource, day time.Time, partial bool) error {
|
||
if source == nil {
|
||
return nil
|
||
}
|
||
start, end := dayRange(day)
|
||
// 当天未结束时截到"现在",否则会把未来时间也查进去
|
||
now := time.Now().UnixNano()
|
||
if end > now {
|
||
end = now
|
||
}
|
||
if start >= end {
|
||
return nil
|
||
}
|
||
// 回补历史时,如果那天的起点已经超出 Loki 保留期(默认 24h),
|
||
// Loki 里根本没那么多数据 —— 必须标成"不完整",
|
||
// 否则图上会把"数据被删了"误读成"那天日志量真的很少"。
|
||
if start < time.Now().Add(-24*time.Hour).UnixNano() {
|
||
partial = true
|
||
}
|
||
|
||
// 只查总量(不带 namespace 条件),一次拿到该源当天的条数与体积
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
stats, err := LokiStatsFor(ctx, source, `{namespace=~".+"}`, start, end)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
date := dateKey(day)
|
||
db := repository.GetDB()
|
||
if db == nil {
|
||
return nil
|
||
}
|
||
db.Lock()
|
||
defer db.Unlock()
|
||
|
||
nextID := uint(0)
|
||
// 用下标取地址:`for _, snap := range` 拿到的是**副本**,
|
||
// 改 snap 的字段不会写回切片(第一版就是这里写错了,
|
||
// 导致同一天永远保持首次抓取的值、partial 不会更新)。
|
||
for i := range db.DailyVolumeSnapshots {
|
||
snap := &db.DailyVolumeSnapshots[i]
|
||
if snap.ID > nextID {
|
||
nextID = snap.ID
|
||
}
|
||
// 同一天同源:覆盖(幂等)
|
||
if snap.Date == date && snap.SourceID == source.ID && snap.Namespace == "" {
|
||
snap.Bytes = stats.Bytes
|
||
snap.Entries = stats.Entries
|
||
snap.Partial = partial
|
||
snap.CapturedAt = time.Now()
|
||
db.RequestSave()
|
||
return nil
|
||
}
|
||
}
|
||
nextID++
|
||
db.DailyVolumeSnapshots = append(db.DailyVolumeSnapshots, models.DailyVolumeSnapshot{
|
||
ID: nextID,
|
||
Date: date,
|
||
SourceID: source.ID,
|
||
Namespace: "",
|
||
Bytes: stats.Bytes,
|
||
Entries: stats.Entries,
|
||
Partial: partial,
|
||
CapturedAt: time.Now(),
|
||
})
|
||
db.RequestSave()
|
||
return nil
|
||
}
|
||
|
||
// CaptureRecentDailyVolumes 回补最近 N 天(含今天),供启动时与定时任务调用。
|
||
func CaptureRecentDailyVolumes() {
|
||
db := repository.GetDB()
|
||
if db == nil {
|
||
return
|
||
}
|
||
db.RLock()
|
||
sources := make([]models.LogSource, 0, len(db.LogSources))
|
||
for _, src := range db.LogSources {
|
||
sources = append(sources, src)
|
||
}
|
||
db.RUnlock()
|
||
|
||
now := time.Now()
|
||
for _, src := range sources {
|
||
src := src
|
||
for offset := dailyVolumeBackfillDays - 1; offset >= 0; offset-- {
|
||
day := now.AddDate(0, 0, -offset)
|
||
// 今天是不完整的一天
|
||
partial := offset == 0
|
||
if err := CaptureDailyVolume(&src, day, partial); err != nil {
|
||
log.Printf("[daily-volume] 抓取 %s %s 失败: %v", src.Name, dateKey(day), err)
|
||
}
|
||
}
|
||
}
|
||
pruneDailyVolumeSnapshots()
|
||
}
|
||
|
||
// pruneDailyVolumeSnapshots 丢弃超过保留期的历史。
|
||
func pruneDailyVolumeSnapshots() {
|
||
db := repository.GetDB()
|
||
if db == nil {
|
||
return
|
||
}
|
||
cutoff := dateKey(time.Now().AddDate(0, 0, -dailyVolumeRetentionDays))
|
||
db.Lock()
|
||
defer db.Unlock()
|
||
kept := make([]models.DailyVolumeSnapshot, 0, len(db.DailyVolumeSnapshots))
|
||
for _, snap := range db.DailyVolumeSnapshots {
|
||
if snap.Date >= cutoff {
|
||
kept = append(kept, snap)
|
||
}
|
||
}
|
||
if len(kept) != len(db.DailyVolumeSnapshots) {
|
||
log.Printf("[daily-volume] 清理过期快照 %d 条", len(db.DailyVolumeSnapshots)-len(kept))
|
||
db.DailyVolumeSnapshots = kept
|
||
db.RequestSave()
|
||
}
|
||
}
|
||
|
||
// GetDailyVolumeHistory 从平台留存的历史里取最近 days 天,按日期升序返回。
|
||
//
|
||
// 今天如果还没抓过,会实时补一次(这样用户切到页面时当天数字是最新的)。
|
||
func GetDailyVolumeHistory(sourceID uint, days int) []DailyVolumePoint {
|
||
if days <= 0 {
|
||
days = 7
|
||
}
|
||
if days > dailyVolumeRetentionDays {
|
||
days = dailyVolumeRetentionDays
|
||
}
|
||
|
||
// 今天实时补抓,保证"今天"这根柱子是活的
|
||
db := repository.GetDB()
|
||
if db != nil {
|
||
db.RLock()
|
||
var target *models.LogSource
|
||
for i := range db.LogSources {
|
||
if db.LogSources[i].ID == sourceID {
|
||
src := db.LogSources[i]
|
||
target = &src
|
||
break
|
||
}
|
||
}
|
||
db.RUnlock()
|
||
if target != nil {
|
||
if err := CaptureDailyVolume(target, time.Now(), true); err != nil {
|
||
log.Printf("[daily-volume] 实时补抓今天失败: %v", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
from := dateKey(time.Now().AddDate(0, 0, -(days - 1)))
|
||
points := []DailyVolumePoint{}
|
||
db = repository.GetDB()
|
||
if db == nil {
|
||
return points
|
||
}
|
||
db.RLock()
|
||
defer db.RUnlock()
|
||
for _, snap := range db.DailyVolumeSnapshots {
|
||
if snap.SourceID != sourceID || snap.Namespace != "" {
|
||
continue
|
||
}
|
||
if snap.Date < from {
|
||
continue
|
||
}
|
||
points = append(points, DailyVolumePoint{
|
||
Date: snap.Date,
|
||
Bytes: snap.Bytes,
|
||
Entries: snap.Entries,
|
||
Partial: snap.Partial,
|
||
FromHistory: true,
|
||
})
|
||
}
|
||
sort.Slice(points, func(i, j int) bool { return points[i].Date < points[j].Date })
|
||
return points
|
||
}
|
||
|
||
// StartDailyVolumeCapture 后台每日抓取。
|
||
//
|
||
// 每 30 分钟检查一次:只要发现"昨天"还没落库就立刻补,
|
||
// 而不是死等 00:10 —— 平台可能整晚都没运行,定时器会错过那个点。
|
||
func StartDailyVolumeCapture() {
|
||
go func() {
|
||
// 启动先补最近两天(覆盖重启场景)
|
||
time.Sleep(20 * time.Second)
|
||
CaptureRecentDailyVolumes()
|
||
|
||
ticker := time.NewTicker(30 * time.Minute)
|
||
defer ticker.Stop()
|
||
for range ticker.C {
|
||
CaptureRecentDailyVolumes()
|
||
}
|
||
}()
|
||
}
|