From 37e2f0febcafd9f35b83a48a311c64f4078bddb7 Mon Sep 17 00:00:00 2001 From: opsx Date: Wed, 28 Oct 2026 22:54:12 +0000 Subject: [PATCH] =?UTF-8?q?feat(analysis,alerts):=20=E6=AF=8F=E6=97=A5?= =?UTF-8?q?=E9=87=8F=E8=90=BD=E5=BA=93=E7=9C=8B=E5=8E=86=E5=8F=B2=E8=B6=8B?= =?UTF-8?q?=E5=8A=BF=E3=80=81=E8=B6=8B=E5=8A=BF=E5=9B=BE/=E9=A5=BC?= =?UTF-8?q?=E5=9B=BE=E5=8F=AF=E8=AF=BB=E6=80=A7=E3=80=81=E5=91=8A=E8=AD=A6?= =?UTF-8?q?=E4=B8=89=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit === 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 --- backend/controllers/logs.go | 16 ++ backend/main.go | 2 + backend/models/daily_volume.go | 29 +++ backend/repository/db.go | 96 ++++--- backend/routers/router.go | 3 +- backend/seed/alert_rules.go | 45 +++- backend/services/daily_volume.go | 243 ++++++++++++++++++ backend/services/notify_channel.go | 8 + frontend/src/api/logs/index.ts | 11 + frontend/src/i18n/locales/en/alerts.ts | 6 + frontend/src/i18n/locales/en/analysis.ts | 26 +- frontend/src/i18n/locales/zh/alerts.ts | 6 + frontend/src/i18n/locales/zh/analysis.ts | 22 +- frontend/src/pages/alerts/index.tsx | 53 ++-- .../analysis/components/DailyVolumeChart.tsx | 114 +++++--- .../analysis/components/TopAppsTable.tsx | 5 +- .../pages/analysis/components/TrendChart.tsx | 99 +++++-- .../pages/analysis/components/chart-shared.ts | 13 + .../pages/analysis/hooks/useDailyVolume.ts | 79 ++---- frontend/src/pages/analysis/index.tsx | 42 +-- 20 files changed, 716 insertions(+), 202 deletions(-) create mode 100644 backend/models/daily_volume.go create mode 100644 backend/services/daily_volume.go diff --git a/backend/controllers/logs.go b/backend/controllers/logs.go index 7539718..cfc25ba 100644 --- a/backend/controllers/logs.go +++ b/backend/controllers/logs.go @@ -328,6 +328,22 @@ func (ctrl *LogsController) Health(c *gin.Context) { utils.Success(c, gin.H{"ready": services.LokiReady(ctx, src)}) } +// DailyVolume 每日日志量历史(平台留存,不受 Loki retention 限制)。 +// +// query: sourceId, days(默认 7,最长 400) +func (ctrl *LogsController) DailyVolume(c *gin.Context) { + sourceID, _ := strconv.Atoi(c.Query("sourceId")) + if sourceID <= 0 { + utils.BadRequest(c, "缺少 sourceId") + return + } + days, _ := strconv.Atoi(c.Query("days")) + if days <= 0 { + days = 7 + } + utils.Success(c, services.GetDailyVolumeHistory(uint(sourceID), days)) +} + // Stats 返回该查询在时间范围内的「总条数/字节/块数」,用于前端展示总页数。 // 走 index/stats(只读索引),成本远低于真数日志。 func (ctrl *LogsController) Stats(c *gin.Context) { diff --git a/backend/main.go b/backend/main.go index 023d2d6..4693898 100644 --- a/backend/main.go +++ b/backend/main.go @@ -141,6 +141,8 @@ func main() { repository.GetDB().RecoverOrphanDeployments() services.StartAlertEngine() + // 每日日志量落库:Loki retention 只有 24h,历史趋势必须自己留 + services.StartDailyVolumeCapture() defer services.StopAlertEngine() r := routers.SetupRouter() diff --git a/backend/models/daily_volume.go b/backend/models/daily_volume.go new file mode 100644 index 0000000..2e9f126 --- /dev/null +++ b/backend/models/daily_volume.go @@ -0,0 +1,29 @@ +package models + +import "time" + +// DailyVolumeSnapshot 每日日志量快照(平台自己留存的历史)。 +// +// 为什么必须落库: +// Loki 的 retention_period 是 24 小时(运维可改,但默认就该短), +// 意味着「近 7 天每日日志量」直接查 Loki 时,只有最近 1 天有数据, +// 其余 6 天是空的 —— 界面上看着像坏了,实际是数据已经不在了。 +// 想要 30 天 / 1 年趋势,只能在数据被删前每天抓一次存进平台库。 +// +// 粒度说明: +// - 只按「数据源 + 命名空间」存。index/volume 系列接口只能按 selector 标签聚合, +// 拿不到按 app 的拆分(Loki 3.4 不支持 aggregateBy), +// 所以按 app 的历史量暂时无法落库,应用维度请看实时排行(有 20~30s 成本)。 +// - 每天一条,日期用**本地时区**的 YYYY-MM-DD,避免跨时区对不上。 +type DailyVolumeSnapshot struct { + ID uint `json:"id"` + Date string `json:"date"` // YYYY-MM-DD(本地时区) + SourceID uint `json:"sourceId"` // 数据源 + Namespace string `json:"namespace"` // "" 表示该数据源全量 + Bytes int64 `json:"bytes"` // 日志体积 + Entries int64 `json:"entries"` // 日志条数 + // Partial 该日期是否为"不完整的一天"(当天抓取时还没过完)。 + // 前端用它区分"真实低峰"和"只统计到一半"。 + Partial bool `json:"partial"` + CapturedAt time.Time `json:"capturedAt"` +} diff --git a/backend/repository/db.go b/backend/repository/db.go index c447f51..f469f34 100644 --- a/backend/repository/db.go +++ b/backend/repository/db.go @@ -40,6 +40,8 @@ type JsonDB struct { LogPolicyGroups []models.LogPolicyGroup `json:"logPolicyGroups"` // AuditLogs 操作审计日志(保留期 90 天 / 上限 2 万条,见 services.auditRetention) AuditLogs []models.AuditLog `json:"auditLogs"` + // DailyVolumeSnapshots 每日日志量历史(Loki retention 只有 24h,历史必须自己留) + DailyVolumeSnapshots []models.DailyVolumeSnapshot `json:"dailyVolumeSnapshots"` // SchemaVersion 数据结构版本号。 // 之前加字段全靠 JSON 反序列化的零值兜底,字段一多必然出事 @@ -111,6 +113,26 @@ func (d *JsonDB) raiseLegacyAlertThresholds() { } } +// linkRulesToDefaultChannel 给未关联通道的规则挂上默认通道。 +// +// 没有通道时不凭空造(那是 seed 首次部署的职责);这里只在已有通道时补齐关联。 +func (d *JsonDB) linkRulesToDefaultChannel() { + if len(d.NotifyChannels) == 0 { + return + } + def := d.NotifyChannels[0].ID + linked := 0 + for i := range d.AlertRules { + if len(d.AlertRules[i].ChannelIDs) == 0 { + d.AlertRules[i].ChannelIDs = []uint{def} + linked++ + } + } + if linked > 0 { + log.Printf("[schema] %d 条告警规则已关联默认通知通道 %q", linked, d.NotifyChannels[0].Name) + } +} + // fixUnreachableLogSourceURLs 把数据源里 0.0.0.0 / [::] 的监听地址换成主机真实 IP。 // // 平台进程不在被采集主机上,0.0.0.0 只在本机有意义,用它必然连不上。 @@ -344,7 +366,7 @@ func (d *JsonDB) dedupeLogSourceIDs() { // // v0/v1(无字段)→ 1:新增 auditLogs / schemaVersion(老库走零值兜底即可,无需处理) // v1 → 2:预留示例(如把 ScrapeJobs 从 logPath 拆分时,需按旧结构生成默认 job) -const CurrentSchemaVersion = 5 +const CurrentSchemaVersion = 6 // migrateSchema 按版本号顺序迁移老库。返回是否发生了迁移(需要回写)。 func (d *JsonDB) migrateSchema() bool { @@ -397,6 +419,13 @@ func (d *JsonDB) migrateSchema() bool { // 只改"仍等于旧默认值"的规则,用户手动调过的阈值不动。 d.raiseLegacyAlertThresholds() d.SchemaVersion = 5 + case 5: + // v5 → v6:把"没有关联任何通道"的告警规则挂到默认通道。 + // + // 之前内置规则 ChannelIDs 全是空的,规则命中后只能去回退旧的全局 + // notify-config;那个早就不用了,结果就是"规则在响但哪里都没发出去"。 + d.linkRulesToDefaultChannel() + d.SchemaVersion = 6 default: // 防御:出现未知版本(比当前代码还新)时不乱动,避免降级把数据写坏 log.Printf("[schema] 数据库版本 %d 高于当前代码支持版本 %d,跳过迁移", d.SchemaVersion, CurrentSchemaVersion) @@ -412,36 +441,38 @@ func InitDB() error { _ = os.MkdirAll(cfg.DataDir, 0700) d := &JsonDB{ - dataFile: cfg.DatabasePath, - Users: []models.User{}, - Clusters: []models.Cluster{}, - Hosts: []models.Host{}, - Storages: []models.StorageConfig{}, - Deployments: []models.LokiDeployment{}, - Collectors: []models.Collector{}, - Logs: []models.DeploymentLog{}, - Settings: []models.SystemSetting{}, - LogSources: []models.LogSource{}, - AlertRules: []models.AlertRule{}, - AlertDeliveries: []models.AlertDelivery{}, - NotifyChannels: []models.NotifyChannel{}, - LogPolicyGroups: []models.LogPolicyGroup{}, - AuditLogs: []models.AuditLog{}, - SchemaVersion: CurrentSchemaVersion, + dataFile: cfg.DatabasePath, + Users: []models.User{}, + Clusters: []models.Cluster{}, + Hosts: []models.Host{}, + Storages: []models.StorageConfig{}, + Deployments: []models.LokiDeployment{}, + Collectors: []models.Collector{}, + Logs: []models.DeploymentLog{}, + Settings: []models.SystemSetting{}, + LogSources: []models.LogSource{}, + AlertRules: []models.AlertRule{}, + AlertDeliveries: []models.AlertDelivery{}, + NotifyChannels: []models.NotifyChannel{}, + LogPolicyGroups: []models.LogPolicyGroup{}, + AuditLogs: []models.AuditLog{}, + DailyVolumeSnapshots: []models.DailyVolumeSnapshot{}, + SchemaVersion: CurrentSchemaVersion, sequences: map[string]uint{ - "users": 0, - "clusters": 0, - "hosts": 0, - "storages": 0, - "deployments": 0, - "collectors": 0, - "logs": 0, - "logPolicyGroups": 0, - "logSources": 0, - "notifyChannels": 0, - "alertRules": 0, - "alertDeliveries": 0, - "auditLogs": 0, + "users": 0, + "clusters": 0, + "hosts": 0, + "storages": 0, + "deployments": 0, + "collectors": 0, + "logs": 0, + "logPolicyGroups": 0, + "logSources": 0, + "notifyChannels": 0, + "alertRules": 0, + "alertDeliveries": 0, + "auditLogs": 0, + "dailyVolumeSnapshots": 0, }, saveChan: make(chan saveRequest, 10), stopChan: make(chan struct{}), @@ -519,6 +550,11 @@ func InitDB() error { d.sequences["auditLogs"] = a.ID } } + for _, v := range d.DailyVolumeSnapshots { + if v.ID > d.sequences["dailyVolumeSnapshots"] { + d.sequences["dailyVolumeSnapshots"] = v.ID + } + } } } diff --git a/backend/routers/router.go b/backend/routers/router.go index 1d406b3..5c3b5fb 100644 --- a/backend/routers/router.go +++ b/backend/routers/router.go @@ -92,7 +92,8 @@ func SetupRouter() *gin.Engine { logs.GET("/labels", logsCtrl.Labels) logs.GET("/label/:name/values", logsCtrl.LabelValues) logs.GET("/my-scope", logsCtrl.MyScope) - logs.POST("/histogram", logsCtrl.Histogram) + logs.GET("/histogram", logsCtrl.Histogram) + logs.GET("/daily-volume", logsCtrl.DailyVolume) // 总量统计(index/stats):只读索引,用于算总页数 logs.POST("/stats", logsCtrl.Stats) logs.POST("/metrics", middleware.AdminAuth(), logsCtrl.Metrics) diff --git a/backend/seed/alert_rules.go b/backend/seed/alert_rules.go index 6ba22dc..62ea9b1 100644 --- a/backend/seed/alert_rules.go +++ b/backend/seed/alert_rules.go @@ -31,6 +31,40 @@ type builtinRule struct { // 3. cooldown 统一 ≥ 120 分钟:同一问题 2 小时内只提醒一次。 // 4. 绝对阈值天然不跨环境通用,大集群(如 300GB/天)必须按 app/namespace // 收窄查询,否则任何阈值都会被常态流量击穿。这一点在 UI 上有提示。 +// +// ensurePlaceholderChannel 返回一个可挂靠的通道 ID: +// 已有通道取第一个;没有则建一个禁用的占位通道。 +func ensurePlaceholderChannel(db *repository.JsonDB) uint { + db.Lock() + defer db.Unlock() + if len(db.NotifyChannels) > 0 { + return db.NotifyChannels[0].ID + } + next := uint(0) + for _, ch := range db.NotifyChannels { + if ch.ID > next { + next = ch.ID + } + } + id := next + 1 + db.NotifyChannels = append(db.NotifyChannels, models.NotifyChannel{ + ID: id, + Name: "默认通知通道(待配置 Webhook)", + Enabled: false, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + return id +} + +// channelIDsFor 把单个通道 ID 包成切片;0 表示没有可用通道(保持空)。 +func channelIDsFor(id uint) []uint { + if id == 0 { + return nil + } + return []uint{id} +} + var builtinRules = []builtinRule{ // —— 致命类:这类日志本该极少出现,但也不能一条就报 —— {name: "致命日志 FATAL", query: `{namespace=~".+"} |= "FATAL"`, windowMin: 15, threshold: 10, cooldown: 120}, @@ -77,6 +111,14 @@ func SeedAlertRules() { // 绑定第一个 managed 数据源(通常是唯一部署) target := sources[0] + // 内置规则一律挂到默认通知通道。 + // 首次部署没有任何通道时,这里建一个**禁用**的占位通道(Webhook 留空): + // - 规则不会变成"孤儿"(发送历史会明确写"通道未投递"而不是静默) + // - 管理员填好 Webhook 再启用即可,不必逐条给规则关联通道 + // - 之所以不放"已启用 + 空地址":那样每次命中都会投递失败刷屏 + // 这里直接操作 db 而不调 services,避免 seed ↔ services 的包循环依赖。 + defaultChannelID := ensurePlaceholderChannel(db) + newID := db.NextID("alertRules") db.Lock() defer db.Unlock() @@ -89,7 +131,8 @@ func SeedAlertRules() { ID: newID, Name: r.name, SourceID: target.id, Query: r.query, WindowMin: r.windowMin, Threshold: r.threshold, Enabled: false, CooldownMin: r.cooldown, - CreatedAt: time.Now(), UpdatedAt: time.Now(), + ChannelIDs: channelIDsFor(defaultChannelID), + CreatedAt: time.Now(), UpdatedAt: time.Now(), }) } db.RequestSave() diff --git a/backend/services/daily_volume.go b/backend/services/daily_volume.go new file mode 100644 index 0000000..a5fe4f8 --- /dev/null +++ b/backend/services/daily_volume.go @@ -0,0 +1,243 @@ +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() + } + }() +} diff --git a/backend/services/notify_channel.go b/backend/services/notify_channel.go index 5f64962..a56475b 100644 --- a/backend/services/notify_channel.go +++ b/backend/services/notify_channel.go @@ -10,6 +10,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "time" "lokistack/models" @@ -60,6 +61,13 @@ func GetNotifyChannelDecrypted(id uint) *models.NotifyChannel { // 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 { diff --git a/frontend/src/api/logs/index.ts b/frontend/src/api/logs/index.ts index ebe85fb..8177f6b 100644 --- a/frontend/src/api/logs/index.ts +++ b/frontend/src/api/logs/index.ts @@ -19,6 +19,14 @@ export interface MetricPoint { timestamp: string; value: number } /** 一条按标签分组的指标序列(如 sum by (app)(...) 中每个 app 一条) */ export interface MetricSeries { labels: Record; points: MetricPoint[] } export interface MetricQuery { sourceId: number; query: string; start: number; end: number } +/** 每日日志量(平台留存历史,不受 Loki retention 限制) */ +export interface DailyVolumePoint { + date: string + bytes: number + entries: number + /** 当天未结束或抓取时数据不全 */ + partial: boolean +} export const logsApi = { sources: () => http.get>('/logs/sources'), createSource: (source: Partial & { basicPass?: string; bearerToken?: string }) => http.post('/logs/sources', source), @@ -37,6 +45,9 @@ export const logsApi = { http.post<{ streams: number; chunks: number; bytes: number; entries: number }>( '/logs/stats', data, { timeout: 25000, ...opts }), /** 日志量统计:后端有 7 天范围上限与 30s 超时,前端超时略大于它 */ + /** 每日日志量历史(读平台库,毫秒级) */ + dailyVolume: (sourceId: number, days: number, opts?: { signal?: AbortSignal }) => + http.get(`/logs/daily-volume?sourceId=${sourceId}&days=${days}`, opts), histogram: (data: LogQuery, opts?: { signal?: AbortSignal }) => http.post>>('/logs/histogram', data, { timeout: 40000, ...opts }), /** LogQL 指标查询(admin):query 中的 [$step] 占位符由后端按时间跨度替换 */ diff --git a/frontend/src/i18n/locales/en/alerts.ts b/frontend/src/i18n/locales/en/alerts.ts index ccde8e0..06d21f4 100644 --- a/frontend/src/i18n/locales/en/alerts.ts +++ b/frontend/src/i18n/locales/en/alerts.ts @@ -7,6 +7,12 @@ export default { checking: 'Checking...', create: 'New Rule', emptyTitle: 'No alert rules yet', + historyEmpty: 'No deliveries yet. Fired rules will be recorded here.', + tabs: { + rules: 'Rules', + channels: 'Channels', + history: 'Delivery history', + }, notifySectionTitle: 'Notification channels (Feishu / Lark / Webhook)', notifySectionConfigured: '{{count}} configured', notifySectionEmpty: 'Not configured — alerts cannot be delivered', diff --git a/frontend/src/i18n/locales/en/analysis.ts b/frontend/src/i18n/locales/en/analysis.ts index 3517c87..3ca6981 100644 --- a/frontend/src/i18n/locales/en/analysis.ts +++ b/frontend/src/i18n/locales/en/analysis.ts @@ -29,8 +29,12 @@ export default { }, trend: { title: 'Log volume trend', - subtitle: 'Total / errors / warnings bucketed over time', + subtitle: 'Log volume per time bucket (from index stats, instant)', + peak: 'Peak', + avg: 'Avg', + unitHint: 'Unit: volume per bucket', seriesTotal: 'Total', + seriesVolume: 'Volume', seriesError: 'Errors', seriesWarn: 'Warnings', empty: 'No log data in this time range', @@ -41,16 +45,22 @@ export default { empty: 'No app-dimension data', }, pie: { - title: 'Namespace distribution', - subtitle: 'Log volume share by namespace', - empty: 'No namespace-dimension data', + title: 'App volume distribution', + subtitle: 'Who is flooding the logs (top 8 apps, rest grouped as Other)', + empty: 'No app-dimension data', other: 'Other', }, daily: { - fixedBadge: 'Fixed last 7 days', - title: 'Daily volume by app', - subtitle: 'Top apps by calendar day for the last 7 days — not controlled by the range filter above', - empty: 'No app-dimension data in the last 7 days', + fixedBadge: 'Platform history', + badge: 'Platform history', + title: 'Daily volume', + subtitle: 'Daily totals kept by the platform (Loki only retains 24h, history lives here)', + empty: 'No history yet — the platform records one snapshot per day', + lastDays: '{{count}}d', + oneYear: '1y', + coverage: '{{count}} days captured', + seriesVolume: 'Volume', + inProgress: 'in progress', }, table: { diff --git a/frontend/src/i18n/locales/zh/alerts.ts b/frontend/src/i18n/locales/zh/alerts.ts index 9e780d3..b3e68e0 100644 --- a/frontend/src/i18n/locales/zh/alerts.ts +++ b/frontend/src/i18n/locales/zh/alerts.ts @@ -6,6 +6,12 @@ export default { checking: '检查中...', create: '新建规则', emptyTitle: '暂无告警规则', + historyEmpty: '还没有发送记录。规则命中后会在这里留下投递结果。', + tabs: { + rules: '告警规则', + channels: '通知通道', + history: '发送历史', + }, notifySectionTitle: '通知通道(飞书 / Lark / Webhook)', notifySectionConfigured: '已配置 {{count}} 个', notifySectionEmpty: '尚未配置,告警发不出去', diff --git a/frontend/src/i18n/locales/zh/analysis.ts b/frontend/src/i18n/locales/zh/analysis.ts index 3837dab..1e1c54e 100644 --- a/frontend/src/i18n/locales/zh/analysis.ts +++ b/frontend/src/i18n/locales/zh/analysis.ts @@ -28,8 +28,12 @@ export default { }, trend: { title: '日志量趋势', - subtitle: '按时间分桶的日志量(来自索引统计,秒级返回)', + subtitle: '按时间分桶的日志体积(来自索引统计,秒级返回)', + peak: '峰值', + avg: '均值', + unitHint: '单位:每桶日志体积', seriesTotal: '总量', + seriesVolume: '日志体积', seriesError: '错误', seriesWarn: '告警', empty: '该时间范围内没有日志数据', @@ -40,16 +44,22 @@ export default { empty: '没有应用维度的数据', }, pie: { - title: '命名空间分布', - subtitle: '各命名空间的日志量占比', + title: '应用日志量分布', + subtitle: '按应用看谁在刷日志(前 8 名,其余归入"其他")', empty: '没有命名空间维度的数据', other: '其他', }, daily: { - fixedBadge: '固定近 7 天', + fixedBadge: '平台留存历史', + badge: '平台留存历史', title: '每日日志量', - subtitle: '近 7 个自然日的每日总量(来自索引统计,不受上方时间范围影响)', - empty: '近 7 天没有应用维度的数据', + subtitle: '每天的总量趋势,数据由平台每日留存(Loki 只保留 24 小时,历史在这里)', + empty: '还没有历史数据,平台每天会自动记录一次', + lastDays: '{{count}} 天', + oneYear: '1 年', + coverage: '已有 {{count}} 天数据', + seriesVolume: '日志体积', + inProgress: '当天进行中', }, table: { diff --git a/frontend/src/pages/alerts/index.tsx b/frontend/src/pages/alerts/index.tsx index 17b5ba1..9ca4fe1 100644 --- a/frontend/src/pages/alerts/index.tsx +++ b/frontend/src/pages/alerts/index.tsx @@ -5,6 +5,7 @@ import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' import { Card, CardContent } from '@/components/ui/card' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { NotifySection } from './components/NotifySection' import { Dialog, DialogContent, DialogHeader, DialogTitle, @@ -87,6 +88,9 @@ const AlertsPage = memo(function AlertsPage() { const [open, setOpen] = useState(false) const [editing, setEditing] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) + // 三个功能区独立成 tab:规则 / 通知通道 / 发送历史。 + // 之前通道和历史都堆在规则表格下面,规则一多就要滚很久才能看到。 + const [tab, setTab] = useState<'rules' | 'channels' | 'history'>('rules') const [deleting, setDeleting] = useState(false) const [checking, setChecking] = useState(false) const [submitting, setSubmitting] = useState(false) @@ -209,6 +213,14 @@ const AlertsPage = memo(function AlertsPage() { } > + setTab(value as 'rules' | 'channels' | 'history')}> + + {t('tabs.rules')} · {rules.length} + {t('tabs.channels')} · {channels.length} + {t('tabs.history')} + + + {rules.length === 0 ? ( )} - {/* 发送历史 */} - {deliveries.length > 0 && ( + + + + {deliveries.length === 0 ? ( + + + {t('historyEmpty')} + + + ) : (

{t('historyTitle')}

@@ -316,24 +336,21 @@ const AlertsPage = memo(function AlertsPage() {
- )} + )} +
- {/* 通知通道配置:从「系统设置」搬到这里。 - 告警规则和"告警发到哪"是同一件事的两半,分两个页面配很容易漏配导致告警发不出去。 - 默认折叠,不影响规则列表的紧凑度。 */} -
- - {t('notifySectionTitle')} - - {channels.length > 0 - ? t('notifySectionConfigured', { count: channels.length }) - : t('notifySectionEmpty')} - - -
+ {/* 通知通道:独立成 tab(原先是折叠块,藏在规则列表最下面) */} + + {channels.length === 0 && ( + + + {t('notifySectionEmpty')} + + + )} -
-
+ +
{/* 规则弹窗 */} diff --git a/frontend/src/pages/analysis/components/DailyVolumeChart.tsx b/frontend/src/pages/analysis/components/DailyVolumeChart.tsx index 1e82965..36d9748 100644 --- a/frontend/src/pages/analysis/components/DailyVolumeChart.tsx +++ b/frontend/src/pages/analysis/components/DailyVolumeChart.tsx @@ -1,37 +1,91 @@ import { memo, useMemo } from 'react' -import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' +import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' import { useTranslation } from 'react-i18next' -import type { DailyAppRow } from '../hooks/useDailyVolume' -import { chartColors } from './chart-shared' +import { Button } from '@/components/ui/button' +import type { DailyVolumePoint } from '@/api/logs' +import { formatBytes } from './chart-shared' import { EmptyChart } from './EmptyChart' -/** 应用每日日志量堆叠柱状图:x 轴日期,系列为 7 天总量 Top 10 应用 */ -export const DailyVolumeChart = memo(function DailyVolumeChart({ days, apps }: { - days: string[] - apps: DailyAppRow[] +const RANGES = [7, 30, 90, 365] as const + +/** + * 每日日志量柱/面积图。 + * + * 数据来自**平台自己留存的历史**(不查 Loki): + * Loki retention 只有 24h,直接查只能拿到 1 天,看不出趋势。 + * 所以这里可以选 7 / 30 / 90 / 365 天,能看多长取决于平台已经积累了多久。 + * + * 当天未结束的那根柱子用虚线区分(Partial),避免把"才过半天"误读成"量掉了"。 + */ +export const DailyVolumeChart = memo(function DailyVolumeChart({ points, days, onDaysChange }: { + points: DailyVolumePoint[] + days: number + onDaysChange: (days: number) => void }) { const { t } = useTranslation('analysis') - // 未打 app 标签的序列用占位 key(recharts dataKey 不能是空串),图例显示词典文案 - const topApps = useMemo(() => apps - .map(app => ({ key: app.app || '__untagged__', label: app.app || t('table.untagged'), totals: app.totals })), [apps, t]) - const data = useMemo(() => days.map((day, index) => ({ - day, - ...Object.fromEntries(topApps.map(app => [app.key, app.totals[index] ?? 0])), - })), [days, topApps]) - if (topApps.length === 0 || days.length === 0) return - return
- - - - - - - - {topApps.map((app, index) => { - const color = chartColors[index % chartColors.length] - return - })} - - -
+ + const data = useMemo(() => points.map(point => ({ + date: point.date.slice(5), // MM-DD,省空间 + bytes: point.bytes, + entries: point.entries, + partial: point.partial, + })), [points]) + + const hasData = data.some(item => item.bytes > 0) + const coverage = points.length + + return ( +
+
+ {RANGES.map(range => ( + + ))} + + {t('daily.coverage', { count: coverage })} + +
+ + {hasData ? ( +
+ + + + + + + + + + + formatBytes(value)} /> + { + const partial = (item?.payload as { partial?: boolean } | undefined)?.partial + return [`${formatBytes(value)}${partial ? ` · ${t('daily.inProgress')}` : ''}`, t('daily.seriesVolume')] + }} + /> + + + +
+ ) : ( + + )} +
+ ) }) diff --git a/frontend/src/pages/analysis/components/TopAppsTable.tsx b/frontend/src/pages/analysis/components/TopAppsTable.tsx index e12434f..bee9ffe 100644 --- a/frontend/src/pages/analysis/components/TopAppsTable.tsx +++ b/frontend/src/pages/analysis/components/TopAppsTable.tsx @@ -2,6 +2,7 @@ import { memo } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import type { AppRow } from '../hooks/useAnalysisMetrics' +import { compactNumber } from './chart-shared' /** 迷你趋势 sparkline:纯 SVG polyline,避免每行一个 recharts 实例 */ function Sparkline({ values }: { values: number[] }) { @@ -46,9 +47,9 @@ export const TopAppsTable = memo(function TopAppsTable({ {index + 1} {row.app || t('table.untagged')} {row.namespace || '—'} - {row.total.toLocaleString()} + {compactNumber(row.total)} 条 {total > 0 ? `${((row.total / total) * 100).toFixed(1)}%` : '—'} - 0 ? 'text-destructive' : 'text-muted-foreground'}`}>{row.errorCount.toLocaleString()} + 0 ? 'text-destructive' : 'text-muted-foreground'}`}>{compactNumber(row.errorCount)} 条 {row.total > 0 ? `${((row.errorCount / row.total) * 100).toFixed(2)}%` : '—'} diff --git a/frontend/src/pages/analysis/components/TrendChart.tsx b/frontend/src/pages/analysis/components/TrendChart.tsx index df47c61..af5e2b7 100644 --- a/frontend/src/pages/analysis/components/TrendChart.tsx +++ b/frontend/src/pages/analysis/components/TrendChart.tsx @@ -1,32 +1,83 @@ -import { memo } from 'react' -import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' +import { memo, useMemo } from 'react' +import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' import { useTranslation } from 'react-i18next' import { EmptyChart } from './EmptyChart' +import { formatBytes } from './chart-shared' interface TrendPoint { time: string; total: number; error: number; warn: number } -/** 日志量趋势折线图:总量 / 错误 / 告警 三条线 */ +/** + * 日志量趋势(体积)。 + * + * 只画 total:total 来自 index/volume_range,单位是**字节**; + * 而 error 来自 count_over_time,单位是**条数**。把两者放进同一个 Y 轴 + * 等于拿字节和条数比大小,刻度完全没有意义。 + * 错误量改由上方统计卡与下方排行表展示(那里是纯条数)。 + * + * 视觉上从"裸折线"换成"渐变面积 + 峰值/均值参考": + * - 面积图更容易看出"这一段整体比上一段高",折线只能靠对齐格子 + * - 顶部给峰值和均值,一眼知道当前水位是否异常(不用自己数刻度) + */ export const TrendChart = memo(function TrendChart({ trend }: { trend: TrendPoint[] }) { const { t } = useTranslation('analysis') - const hasData = trend.some(point => point.total > 0 || point.error > 0 || point.warn > 0) - if (!hasData) return - const series = [ - { key: 'total', name: t('trend.seriesTotal'), color: 'hsl(var(--info))' }, - { key: 'error', name: t('trend.seriesError'), color: 'hsl(var(--destructive))' }, - { key: 'warn', name: t('trend.seriesWarn'), color: 'hsl(var(--warning))' }, - ] as const - return
- - - - - - - - {series.map(item => ( - - ))} - - -
+ const hasData = trend.some(point => point.total > 0) + + const summary = useMemo(() => { + const values = trend.map(point => point.total).filter(value => value > 0) + if (values.length === 0) return null + const peak = Math.max(...values) + const avg = values.reduce((sum, value) => sum + value, 0) / values.length + return { peak, avg } + }, [trend]) + + if (!hasData || !summary) return + + return ( +
+
+ + {t('trend.peak')} {formatBytes(summary.peak)} + + + {t('trend.avg')} {formatBytes(summary.avg)} + + {t('trend.unitHint')} +
+
+ + + + + + + + + + + formatBytes(value)} + // 峰值贴近顶部时留点余量,避免线贴边看不清 + domain={[0, (dataMax: number) => dataMax * 1.15]} + /> + [formatBytes(value), t('trend.seriesVolume')]} + labelFormatter={label => `${label}`} + /> + + + +
+
+ ) }) diff --git a/frontend/src/pages/analysis/components/chart-shared.ts b/frontend/src/pages/analysis/components/chart-shared.ts index 7f437e1..b4cb3d0 100644 --- a/frontend/src/pages/analysis/components/chart-shared.ts +++ b/frontend/src/pages/analysis/components/chart-shared.ts @@ -8,6 +8,19 @@ export const chartColors = [ 'hsl(var(--muted-foreground))', ] +/** 字节格式化:7956179750 → 7.4 GB(索引接口返回的是字节) */ +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] + let value = bytes + let unit = 0 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit++ + } + return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}` +} + /** 数字缩写:1.2万 / 3.4k(表格与图例用,避免长数字撑爆列宽) */ export function compactNumber(value: number): string { if (value >= 100000000) return `${(value / 100000000).toFixed(1)}亿` diff --git a/frontend/src/pages/analysis/hooks/useDailyVolume.ts b/frontend/src/pages/analysis/hooks/useDailyVolume.ts index 97d926e..a6b342e 100644 --- a/frontend/src/pages/analysis/hooks/useDailyVolume.ts +++ b/frontend/src/pages/analysis/hooks/useDailyVolume.ts @@ -1,34 +1,25 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { logsApi } from '@/api/logs' - -export interface DailyAppRow { - app: string - namespace: string - /** 每个自然日(本地时区)的日志总量,与 days 一一对应 */ - totals: number[] - total: number -} +import { logsApi, type DailyVolumePoint } from '@/api/logs' export interface DailyVolumeData { - days: string[] - apps: DailyAppRow[] + points: DailyVolumePoint[] + /** 历史覆盖的最早日期(用于提示"可查看范围") */ + earliest: string } -const DAY_MS = 86400000 -const RANGE_DAYS = 7 -const ns = (ms: number) => Math.round(ms).toString() + '000000' -const dayLabel = (ts: number) => new Date(ts).toLocaleDateString([], { month: '2-digit', day: '2-digit' }) - /** - * 应用每日日志量(近 7 天,Top 10)— 独立于页面时间范围筛选: - * 一条 `sum by (app, namespace)(count_over_time(sel[$step]))` 查询(7 天跨度后端自动选 1h 步长), - * 客户端把小时桶按本地日历日聚合,避免为每天单独发查询。 + * 每日日志量(平台留存历史,不走 Loki 实时统计)。 + * + * 为什么要落库: + * Loki retention 默认 24 小时,「近 7 天」直接查 Loki 只有 1 天有数据。 + * 平台后台每 30 分钟把「昨天 / 今天」的量写进自己的库, + * 所以这里读到的是真历史,能支撑 30 天 / 90 天 / 1 年的趋势。 + * + * 成本:一次 HTTP 读 JSON(毫秒级),不碰 Loki。 */ -export function useDailyVolume({ sourceId, namespace, appLabel, enabled = true }: { +export function useDailyVolume({ sourceId, days, enabled = true }: { sourceId: number - namespace: string - appLabel: string - /** 卡片滚动到视口内才发查询(索引接口很快,但没必要首屏就抢带宽) */ + days: number enabled?: boolean }) { const [data, setData] = useState(null) @@ -42,55 +33,21 @@ export function useDailyVolume({ sourceId, namespace, appLabel, enabled = true } useEffect(() => { if (!sourceId || !enabled) return - const end = Date.now() - // 从今天往前取整 7 个自然日(首尾两天可能是不完整天) - const today = Math.floor(end / DAY_MS) * DAY_MS - const days = Array.from({ length: RANGE_DAYS }, (_, index) => today - (RANGE_DAYS - 1 - index) * DAY_MS) - const start = days[0] - abortRef.current?.abort() const controller = new AbortController() abortRef.current = controller const version = ++versionRef.current - const selector = namespace ? `{namespace="${namespace}"}` : '{namespace=~".+"}' void (async () => { setLoading(true) setError('') try { - // 走索引接口而不是 count_over_time。 - // - // 原来这条查 7 天 × count_over_time,实测该环境约 370GB/天 → 要扫 2.6TB, - // 后端 90s 也跑不完,图表永远空/超时。 - // /logs/histogram 内部用 index/volume_range,只读 chunk 元数据, - // 7 天跨度也是毫秒级;代价是**只能拿到总量,拿不到 per-app 拆分** - // (Loki 的索引接口不支持按任意 label 分组)。 - // 因此这里展示"每日日志总量",应用维度交给下方的 Top 应用表格(它有单独的时间范围)。 - const hist = await logsApi.histogram( - { sourceId, query: selector, start: Number(ns(start)), end: Number(ns(end)) }, - { signal: controller.signal }, - ) + const points = await logsApi.dailyVolume(sourceId, days, { signal: controller.signal }) if (version !== versionRef.current) return - - // 索引返回的是字节数,按本地自然日聚合 - const dayBytes = days.map(() => 0) - for (const bucket of (hist || [])) { - const ts = String((bucket as { timestamp: string }).timestamp ?? '') - const value = Number((bucket as { count: number }).count ?? 0) - const day = Math.floor(new Date(ts).getTime() / DAY_MS) * DAY_MS - const index = days.indexOf(day) - if (index >= 0) dayBytes[index] += value - } setData({ - days: days.map(dayLabel), - apps: [{ - app: '全部应用', - namespace: namespace || '', - totals: dayBytes, - total: dayBytes.reduce((sum, value) => sum + value, 0), - }], + points: points ?? [], + earliest: points?.[0]?.date ?? '', }) - return } catch (err) { if ((err as Error).name === 'AbortError') return if (version === versionRef.current) setError(err instanceof Error ? err.message : 'daily volume failed') @@ -100,7 +57,7 @@ export function useDailyVolume({ sourceId, namespace, appLabel, enabled = true } })() return () => { controller.abort() } - }, [sourceId, namespace, appLabel, refreshTick, enabled]) + }, [sourceId, days, refreshTick, enabled]) return { data, loading, error, refresh } } diff --git a/frontend/src/pages/analysis/index.tsx b/frontend/src/pages/analysis/index.tsx index 4ecc457..20dc2df 100644 --- a/frontend/src/pages/analysis/index.tsx +++ b/frontend/src/pages/analysis/index.tsx @@ -17,6 +17,7 @@ import { getUser } from '@/lib/auth-storage' import { useAnalysisMetrics } from './hooks/useAnalysisMetrics' import { useDailyVolume } from './hooks/useDailyVolume' import { useInView } from '@/hooks/useInView' +import { compactNumber, formatBytes } from './components/chart-shared' import { TrendChart } from './components/TrendChart' import { AppStreamChart } from './components/AppStreamChart' import { DailyVolumeChart } from './components/DailyVolumeChart' @@ -36,19 +37,7 @@ const ALL_NAMESPACES = '__all__' const AnalysisPage = memo(function AnalysisPage() { const { t } = useTranslation('analysis') const navigate = useNavigate() - /** 索引接口返回的是字节数,直接 toLocaleString 会显示 7956179750 这种看不懂的数字 */ -const formatBytes = (bytes: number) => { - const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] - let value = bytes - let unit = 0 - while (value >= 1024 && unit < units.length - 1) { - value /= 1024 - unit++ - } - return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}` -} - -const isAdmin = getUser()?.role === 'admin' + const isAdmin = getUser()?.role === 'admin' const [sources, setSources] = useState([]) const [sourceId, setSourceId] = useState(0) @@ -142,10 +131,11 @@ const isAdmin = getUser()?.role === 'admin' rankingEnabled: rankingView.inView, }) // 每日统计固定近 7 天,不随上方时间范围变化;错误就地展示,不影响主区 + // 每日量可看范围:7 / 30 / 90 / 365 天(数据来自平台留存历史) + const [dailyDays, setDailyDays] = useState(7) const daily = useDailyVolume({ sourceId: effectiveSourceId, - namespace: scopedNamespace, - appLabel, + days: dailyDays, enabled: dailyView.inView, }) @@ -226,10 +216,10 @@ const isAdmin = getUser()?.role === 'admin' {/* 不再显示"告警行数"卡片:为了把它从 3 条重查询降到 2 条, 已放弃全局 warn 统计。显示一个恒为 0 的数字比不显示更误导。 */}
- + - - + +
@@ -245,7 +235,13 @@ const isAdmin = getUser()?.role === 'admin' {t('pie.title')}

{t('pie.subtitle')}

- + + {/* 从"命名空间分布"改成"应用分布":命名空间只有 astralx/log 两个, + 看了没有信息量;按应用才看得出是谁在刷日志 */} + ({ name: app.app || t('table.untagged'), count: app.total }))} + /> +
@@ -261,7 +257,7 @@ const isAdmin = getUser()?.role === 'admin'
{t('daily.title')} - {t('daily.fixedBadge')} + {t('daily.badge')}

{t('daily.subtitle')}

@@ -271,7 +267,11 @@ const isAdmin = getUser()?.role === 'admin' ) : daily.loading && !daily.data ? ( ) : ( - + )}