diff --git a/backend/controllers/logs.go b/backend/controllers/logs.go index 600d59e..757d2b3 100644 --- a/backend/controllers/logs.go +++ b/backend/controllers/logs.go @@ -349,8 +349,60 @@ func (ctrl *LogsController) AppRanking(c *gin.Context) { limit = v } } - rows := services.GetAppRanking(uint(sourceID), time.Unix(0, from), time.Unix(0, to), limit) - utils.Success(c, gin.H{"list": rows, "hours": services.AppVolumeCoverage(uint(sourceID))}) + rows := services.GetAppRanking(uint(sourceID), time.Unix(0, from), time.Unix(0, to), limit, c.DefaultQuery("sort", "bytes")) + hours, bytesHours := services.AppVolumeCoverageInfo(uint(sourceID)) + utils.Success(c, gin.H{"list": rows, "hours": hours, "bytesHours": bytesHours}) +} + +// LabelValuesBatch 一次取多个 label 的候选值:GET /logs/label-values?names=app,pod&query={...} +// +// 快捷查询的下拉是级联的(命名空间 → 应用 → Pod),而"应用"本身要走 +// app → service_name → container 三级回退。逐个请求时前端一次要发 3 个往返, +// 每个都要经 K8s API 代理到 Loki 索引;合并成一个接口后只发一次。 +// 返回 {"app": [...], "service_name": [...], ...},单个 label 失败返回空数组不报错。 +func (ctrl *LogsController) LabelValuesBatch(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("sourceId"), 10, 64) + src := services.GetLogSource(uint(id)) + if src == nil { + utils.BadRequest(c, "数据源不存在") + return + } + raw := strings.TrimSpace(c.Query("names")) + if raw == "" { + utils.BadRequest(c, "缺少 names(逗号分隔的 label 名,如 app,pod)") + return + } + names := make([]string, 0, 4) + for _, name := range strings.Split(raw, ",") { + if name = strings.TrimSpace(name); name != "" { + names = append(names, name) + } + } + if len(names) == 0 { + utils.BadRequest(c, "names 里没有有效的 label 名") + return + } + if len(names) > 10 { + utils.BadRequest(c, "一次最多取 10 个 label 的值") + return + } + requestedQuery := strings.TrimSpace(c.Query("query")) + if requestedQuery == "" { + requestedQuery = "{}" + } + query, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", requestedQuery) + if err != nil { + utils.Forbidden(c, err.Error()) + return + } + start, _ := strconv.ParseInt(c.Query("start"), 10, 64) + end, _ := strconv.ParseInt(c.Query("end"), 10, 64) + values, err := services.LokiLabelValuesBatch(c.Request.Context(), src, names, query, start, end) + if err != nil { + utils.Error(c, 502, err.Error()) + return + } + utils.Success(c, values) } // DailyVolume 每日日志量历史(平台留存,不受 Loki retention 限制)。 diff --git a/backend/models/app_volume.go b/backend/models/app_volume.go index b4bb6c0..a996322 100644 --- a/backend/models/app_volume.go +++ b/backend/models/app_volume.go @@ -26,7 +26,15 @@ type AppVolumeSnapshot struct { SourceID uint `json:"sourceId"` Namespace string `json:"namespace"` App string `json:"app"` - Entries int64 `json:"entries"` + // Entries 该小时的日志条数(count_over_time,要读日志内容,成本高但准确) + Entries int64 `json:"entries"` + // Bytes 该小时的日志体积(字节,来自 index/volume 索引接口) + // + // 为什么不跟 Entries 用同一条查询:按 app 取体积在 Loki 里没有"一次扫全集群"的 + // 索引聚合(index/volume 的返回只带 selector 里出现的 label),所以只能一个 app + // 调一次;换来的是 80ms/次的索引查询,而不是重扫整小时日志内容(约 50s)。 + // 老快照(本字段上线前抓的)Bytes=0,前端据此把"体积未知"和"体积真的是 0"分开。 + Bytes int64 `json:"bytes"` // Partial 该小时数据是否不完整(如平台停了一段时间后才回补,超出 Loki 保留期) Partial bool `json:"partial"` CapturedAt time.Time `json:"capturedAt"` diff --git a/backend/routers/router.go b/backend/routers/router.go index 1bcd852..198c54c 100644 --- a/backend/routers/router.go +++ b/backend/routers/router.go @@ -96,6 +96,9 @@ func SetupRouter() *gin.Engine { logs.POST("/context", logsCtrl.Context) logs.GET("/labels", logsCtrl.Labels) logs.GET("/label/:name/values", logsCtrl.LabelValues) + // 批量取 label 值:一次请求拿多个 label(app+service_name+container 三级回退), + // 前端下拉联动从 3 个往返降到 1 个 + logs.GET("/label-values", logsCtrl.LabelValuesBatch) logs.GET("/my-scope", logsCtrl.MyScope) // ⚠️ 必须是 POST:前端 logsApi.histogram 用 POST 传 body(sourceId/query/start/end)。 // 曾被误改成 GET,导致 POST /logs/histogram 全部 404, diff --git a/backend/routers/router_test.go b/backend/routers/router_test.go index 8952d23..ab903ec 100644 --- a/backend/routers/router_test.go +++ b/backend/routers/router_test.go @@ -34,6 +34,8 @@ func TestCriticalRoutes(t *testing.T) { {"GET", "/api/v1/logs/sources"}, {"GET", "/api/v1/logs/labels"}, {"GET", "/api/v1/logs/label/:name/values"}, + // 批量 label 值(下拉联动一次请求)—— 路由写错就是前端全部下拉空 + {"GET", "/api/v1/logs/label-values"}, {"GET", "/api/v1/logs/my-scope"}, {"GET", "/api/v1/logs/daily-volume"}, {"GET", "/api/v1/logs/health"}, diff --git a/backend/services/app_volume.go b/backend/services/app_volume.go index 0384131..7873677 100644 --- a/backend/services/app_volume.go +++ b/backend/services/app_volume.go @@ -8,6 +8,7 @@ import ( "net/url" "sort" "strconv" + "strings" "time" "lokistack/models" @@ -36,7 +37,11 @@ const ( type AppRankRow struct { App string `json:"app"` Namespace string `json:"namespace"` - Entries int64 `json:"entries"` + // Entries 日志条数(预聚合快照累加) + Entries int64 `json:"entries"` + // Bytes 日志体积(字节,预聚合快照累加)。 + // 快照里没有体积的早期小时会让这个值偏小 —— 响应里的 bytesHours 用来提示覆盖率。 + Bytes int64 `json:"bytes"` // FromHistory 该行来自平台预聚合(而不是实时扫日志) FromHistory bool `json:"fromHistory"` } @@ -86,6 +91,7 @@ func CaptureAppVolumeForHour(src *models.LogSource, hour time.Time) error { type row struct { ns, app string entries int64 + bytes int64 } rows := make([]row, 0, len(resp.Data.Result)) for _, item := range resp.Data.Result { @@ -114,6 +120,29 @@ func CaptureAppVolumeForHour(src *models.LogSource, hour time.Time) error { return nil } + // ── 体积:逐个 app 走 index/volume(索引接口,实测 ~80ms/个)── + // + // 为什么不用一条 `sum by (app) (bytes_over_time(...))` 拿全:那条要重扫整小时 + // 的日志内容(本集群约 29GB/小时,15 分钟就要 13s,一小时约 50s),而 + // index/volume 只读 chunk 元数据。代价是必须一个 app 一次调用(index/volume + // 的返回值只带 selector 里出现过的 label),50 个 app ≈ 4~10s。 + // + // 体积失败**不影响条数落库**:拿不到就留 0,前端把 0 显示成"体积未知", + // 排行仍可按条数排(GetAppRanking 会按可用指标退让)。 + vctx, vcancel := context.WithTimeout(context.Background(), 2*time.Minute) + for i := range rows { + if rows[i].app == "" { + continue + } + bytes, err := LokiVolumeBytesFor(vctx, src, appVolumeSelector(rows[i].ns, rows[i].app), start.UnixNano(), end.UnixNano()) + if err != nil { + log.Printf("[app-volume] %s %s/%s 体积获取失败(条数已留存): %v", key, rows[i].ns, rows[i].app, err) + continue + } + rows[i].bytes = bytes + } + vcancel() + db := repository.GetDB() if db == nil { return fmt.Errorf("数据库未初始化") @@ -128,6 +157,7 @@ func CaptureAppVolumeForHour(src *models.LogSource, hour time.Time) error { snap := &db.AppVolumeSnapshots[i] if snap.Hour == key && snap.SourceID == src.ID && snap.Namespace == r.ns && snap.App == r.app { snap.Entries = r.entries + snap.Bytes = r.bytes snap.CapturedAt = time.Now() found = true break @@ -138,7 +168,7 @@ func CaptureAppVolumeForHour(src *models.LogSource, hour time.Time) error { } db.AppVolumeSnapshots = append(db.AppVolumeSnapshots, models.AppVolumeSnapshot{ ID: nextID, Hour: key, SourceID: src.ID, Namespace: r.ns, App: r.app, - Entries: r.entries, CapturedAt: time.Now(), + Entries: r.entries, Bytes: r.bytes, CapturedAt: time.Now(), }) nextID++ } @@ -213,8 +243,29 @@ func StartAppVolumeCapture() { }() } +// appVolumeSelector 生成 index/volume 用的 stream selector。 +// 值用 strconv.Quote 转义:app 名理论上可能出现引号/反斜杠,拼错就是 Loki 400。 +func appVolumeSelector(namespace, app string) string { + parts := make([]string, 0, 2) + if namespace != "" { + parts = append(parts, "namespace="+strconv.Quote(namespace)) + } + if app != "" { + parts = append(parts, "app="+strconv.Quote(app)) + } + if len(parts) == 0 { + return `{namespace=~".+"}` + } + return "{" + strings.Join(parts, ", ") + "}" +} + // GetAppRanking 从**预聚合快照**里取指定时间范围的应用排行(毫秒级)。 -func GetAppRanking(sourceID uint, from, to time.Time, limit int) []AppRankRow { +// +// sortBy:bytes(默认,按体积)| entries(按条数)。 +// 体积优先是因为用户要的是"哪个应用把磁盘/带宽吃了";条数受日志长短影响, +// 一个高频 INFO 应用可以条数第一但体积很小。 +// 排序永远带兜底:体积相同(或都是 0)时按条数,再按应用名,保证顺序稳定。 +func GetAppRanking(sourceID uint, from, to time.Time, limit int, sortBy string) []AppRankRow { if limit <= 0 { limit = 10 } @@ -231,6 +282,7 @@ func GetAppRanking(sourceID uint, from, to time.Time, limit int) []AppRankRow { type agg struct { ns, app string entries int64 + bytes int64 } sum := map[string]*agg{} for _, snap := range db.AppVolumeSnapshots { @@ -244,16 +296,34 @@ func GetAppRanking(sourceID uint, from, to time.Time, limit int) []AppRankRow { sum[key] = item } item.entries += snap.Entries + item.bytes += snap.Bytes } rows := make([]AppRankRow, 0, len(sum)) for _, item := range sum { - rows = append(rows, AppRankRow{App: item.app, Namespace: item.ns, Entries: item.entries, FromHistory: true}) + rows = append(rows, AppRankRow{ + App: item.app, Namespace: item.ns, + Entries: item.entries, Bytes: item.bytes, FromHistory: true, + }) } + byEntries := sortBy == "entries" sort.Slice(rows, func(i, j int) bool { - if rows[i].Entries == rows[j].Entries { - return rows[i].App < rows[j].App + a, b := rows[i], rows[j] + if byEntries { + if a.Entries != b.Entries { + return a.Entries > b.Entries + } + if a.Bytes != b.Bytes { + return a.Bytes > b.Bytes + } + } else { + if a.Bytes != b.Bytes { + return a.Bytes > b.Bytes + } + if a.Entries != b.Entries { + return a.Entries > b.Entries + } } - return rows[i].Entries > rows[j].Entries + return a.App < b.App }) if len(rows) > limit { rows = rows[:limit] @@ -263,17 +333,32 @@ func GetAppRanking(sourceID uint, from, to time.Time, limit int) []AppRankRow { // AppVolumeCoverage 返回快照覆盖的小时数,前端用来提示"历史只有 N 小时数据"。 func AppVolumeCoverage(sourceID uint) int { + hours, _ := AppVolumeCoverageInfo(sourceID) + return hours +} + +// AppVolumeCoverageInfo 返回(快照覆盖小时数, 其中带体积的小时数)。 +// +// 第二个值是给"按体积排行"用的诚实性提示:体积列是本轮新加的, +// 之前抓的小时只有条数没有体积,24h 排行里这些小时的体积会偏小。 +// 两个数不一致时前端要明确告诉用户覆盖率,而不是让用户误读成"这个应用体积很小"。 +func AppVolumeCoverageInfo(sourceID uint) (hours, bytesHours int) { db := repository.GetDB() if db == nil { - return 0 + return 0, 0 } db.RLock() defer db.RUnlock() - hours := map[string]bool{} + all := map[string]bool{} + withBytes := map[string]bool{} for _, snap := range db.AppVolumeSnapshots { - if snap.SourceID == sourceID { - hours[snap.Hour] = true + if snap.SourceID != sourceID { + continue + } + all[snap.Hour] = true + if snap.Bytes > 0 { + withBytes[snap.Hour] = true } } - return len(hours) + return len(all), len(withBytes) } diff --git a/backend/services/daily_volume.go b/backend/services/daily_volume.go index 3fe1b51..672d6ce 100644 --- a/backend/services/daily_volume.go +++ b/backend/services/daily_volume.go @@ -170,10 +170,10 @@ func upsertVolumeSnapshot(sourceID uint, date string, bytes int64, partial bool) // 实测 6ms(空闲)~ 20s(有并发查询时)。每 30 分钟 × 每一天都重跑一遍, // 既浪费 Loki 的 CPU,又会和用户页面上的查询抢同一台单实例的算力。所以: // -// • 只处理 Date < 今天 的天(今天没结束,条数没有意义) -// • 每天最多 entriesMaxAttempts 次,两次间隔 ≥ entriesRetryInterval -// • 尝试次数**先写库再查**:进程中途被杀也不会退化成"每 30 分钟重试" -// • 拿不到就留 0,前端图只画体积,不影响可读性 +// - 只处理 Date < 今天 的天(今天没结束,条数没有意义) +// - 每天最多 entriesMaxAttempts 次,两次间隔 ≥ entriesRetryInterval +// - 尝试次数**先写库再查**:进程中途被杀也不会退化成"每 30 分钟重试" +// - 拿不到就留 0,前端图只画体积,不影响可读性 func captureDailyEntries(source *models.LogSource) { if source == nil { return @@ -311,16 +311,31 @@ func pruneDailyVolumeSnapshots() { today := dateKey(time.Now()) db.Lock() defer db.Unlock() + known := make(map[uint]bool, len(db.LogSources)) + for _, src := range db.LogSources { + known[src.ID] = true + } changed := false kept := make([]models.DailyVolumeSnapshot, 0, len(db.DailyVolumeSnapshots)) + droppedOrphan := 0 for _, snap := range db.DailyVolumeSnapshots { + // 超过留存期 if snap.Date < cutoff { continue } + // 数据源已被删除:它的快照没有任何入口能查到,留着只会混淆取证 + // (实测遇到过库里留着 id=2 的历史,查"到底抓没抓"时白查一轮) + if !known[snap.SourceID] { + droppedOrphan++ + continue + } kept = append(kept, snap) } + if droppedOrphan > 0 { + log.Printf("[daily-volume] 清理已删除数据源的快照 %d 条", droppedOrphan) + } if len(kept) != len(db.DailyVolumeSnapshots) { - log.Printf("[daily-volume] 清理过期快照 %d 条", len(db.DailyVolumeSnapshots)-len(kept)) + log.Printf("[daily-volume] 清理过期快照 %d 条", len(db.DailyVolumeSnapshots)-len(kept)-droppedOrphan) db.DailyVolumeSnapshots = kept changed = true } @@ -414,10 +429,66 @@ func StartDailyVolumeCapture() { log.Printf("[daily-volume] 启动回补最近 %d 天", dailyVolumeBackfillDays) CaptureRecentDailyVolumes() + // 两条节奏: + // 30 分钟 ticker → 刷新"今天"的柱子(当天数据一直在涨) + // 零点后 1 分钟 → **结算昨天**(把它定稿成完整一天) + // + // 为什么零点结算必须对齐而不能靠 30 分钟 ticker: + // Loki 保留期默认 24h,昨天 00:00 的数据在今天 00:00 之后就开始被删。 + // 如果等到 00:30 那次 ticker 才抓,昨天最早的 30 分钟已经没了, + // 结算结果会系统性偏低,而图上又标着"完整一天",属于静默错数。 + // 00:01 结算时最多只丢 1 分钟,误差可忽略。 ticker := time.NewTicker(30 * time.Minute) defer ticker.Stop() - for range ticker.C { - CaptureRecentDailyVolumes() + midnight := time.NewTimer(time.Until(nextMidnightFinalize())) + defer midnight.Stop() + + for { + select { + case <-ticker.C: + CaptureRecentDailyVolumes() + case <-midnight.C: + FinalizeYesterdayVolume() + midnight.Reset(time.Until(nextMidnightFinalize())) + } } }() } + +// nextMidnightFinalize 返回下一个"零点后 1 分钟"的时间点。 +func nextMidnightFinalize() time.Time { + now := time.Now() + next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 1, 0, 0, time.Local) + return next +} + +// FinalizeYesterdayVolume 零点后结算昨天:把昨天定稿(partial=false), +// 同时刷新今天的 partial=true 柱子。多数据源逐个处理,单个失败不影响其它。 +func FinalizeYesterdayVolume() { + 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() + yesterday := now.AddDate(0, 0, -1) + for _, src := range sources { + src := src + if err := CaptureDailyVolume(&src, yesterday, false); err != nil { + log.Printf("[daily-volume] 零点结算 %s %s 失败: %v", src.Name, dateKey(yesterday), err) + continue + } + log.Printf("[daily-volume] 零点结算完成 %s %s", src.Name, dateKey(yesterday)) + // 昨天定稿后,条数也可以补一次(走 index/stats,慢但一天一次可接受) + captureDailyEntries(&src) + if err := CaptureDailyVolume(&src, now, true); err != nil { + log.Printf("[daily-volume] 刷新今天 %s 失败: %v", src.Name, err) + } + } +} diff --git a/backend/services/daily_volume_capture_test.go b/backend/services/daily_volume_capture_test.go new file mode 100644 index 0000000..797bc08 --- /dev/null +++ b/backend/services/daily_volume_capture_test.go @@ -0,0 +1,102 @@ +package services + +import ( + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "lokistack/models" + "lokistack/repository" +) + +// 条数(index/stats)只给"已结束且还没有条数"的天补,并且每天最多 3 次。 +// 这条护栏的直接目的:别让 30 分钟一次的定时任务把整天 index/stats 反复重跑。 +func TestCaptureDailyEntriesOnlyForPastDaysAndWrites(t *testing.T) { + setupDailyVolumeDB(t) + + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + if r.URL.Path != "/loki/api/v1/index/stats" { + t.Errorf("意外请求路径: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"streams":3,"chunks":9,"bytes":123456,"entries":456}`) + })) + defer srv.Close() + + src := &models.LogSource{ID: 1, Name: "test", TargetType: "url", URL: srv.URL} + yesterday := dateKey(time.Now().AddDate(0, 0, -1)) + today := dateKey(time.Now()) + + db := repository.GetDB() + db.Lock() + db.DailyVolumeSnapshots = []models.DailyVolumeSnapshot{ + {ID: 1, Date: yesterday, SourceID: 1, Bytes: 100}, + {ID: 2, Date: today, SourceID: 1, Bytes: 10, Partial: true}, + } + db.Unlock() + + captureDailyEntries(src) + + got := snapshotsByDate(t) + if snap := got[yesterday]; snap.Entries != 456 { + t.Fatalf("昨天的条数应写入 456,得到 %d", snap.Entries) + } + if snap := got[yesterday]; snap.EntriesAttempts != 1 { + t.Errorf("昨天应记录 1 次尝试,得到 %d", snap.EntriesAttempts) + } + if snap := got[today]; snap.EntriesAttempts != 0 || snap.Entries != 0 { + t.Errorf("今天没结束,不应抓条数(attempts=%d entries=%d)", snap.EntriesAttempts, snap.Entries) + } + if n := atomic.LoadInt32(&calls); n != 1 { + t.Errorf("应只打 1 次 index/stats,实际 %d 次", n) + } + + // 1 小时内重复触发不应再打 Loki + captureDailyEntries(src) + if n := atomic.LoadInt32(&calls); n != 1 { + t.Errorf("重试间隔内不应再查询,实际 %d 次", n) + } +} + +// 失败(Loki 报错)时必须吃得下,并且最多尝试 entriesMaxAttempts 次就放弃。 +func TestCaptureDailyEntriesRetryBudget(t *testing.T) { + setupDailyVolumeDB(t) + + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() + + src := &models.LogSource{ID: 1, Name: "test", TargetType: "url", URL: srv.URL} + yesterday := dateKey(time.Now().AddDate(0, 0, -1)) + + db := repository.GetDB() + db.Lock() + db.DailyVolumeSnapshots = []models.DailyVolumeSnapshot{{ID: 1, Date: yesterday, SourceID: 1, Bytes: 100}} + db.Unlock() + + // 模拟"每轮定时任务都到点":把上次尝试时间清空,绕过 1 小时间隔 + for i := 0; i < entriesMaxAttempts+3; i++ { + captureDailyEntries(src) + db.Lock() + for j := range db.DailyVolumeSnapshots { + db.DailyVolumeSnapshots[j].LastEntriesAttempt = time.Time{} + } + db.Unlock() + } + + if n := atomic.LoadInt32(&calls); n != entriesMaxAttempts { + t.Errorf("应恰好尝试 %d 次后放弃,实际 %d 次", entriesMaxAttempts, n) + } + if snap := snapshotsByDate(t)[yesterday]; snap.Entries != 0 || snap.EntriesAttempts != entriesMaxAttempts { + t.Errorf("失败后应留 entries=0 / attempts=%d,得到 entries=%d attempts=%d", + entriesMaxAttempts, snap.Entries, snap.EntriesAttempts) + } +} diff --git a/backend/services/daily_volume_test.go b/backend/services/daily_volume_test.go index efac0bb..5bf71d0 100644 --- a/backend/services/daily_volume_test.go +++ b/backend/services/daily_volume_test.go @@ -17,6 +17,15 @@ func setupDailyVolumeDB(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = repository.Shutdown() }) + + // 每天的快照都挂着数据源,prune 会清掉"数据源已删除"的孤儿快照, + // 所以测试里先登记好 id=1 的数据源。 + db := repository.GetDB() + db.Lock() + if len(db.LogSources) == 0 { + db.LogSources = append(db.LogSources, models.LogSource{ID: 1, Name: "test-loki", TargetType: "url"}) + } + db.Unlock() } func snapshotsByDate(t *testing.T) map[string]models.DailyVolumeSnapshot { @@ -56,6 +65,8 @@ func TestPruneDailyVolumeNormalizesPartialAndDropsExpired(t *testing.T) { {ID: 3, Date: dateKey(now), SourceID: 1, Bytes: 10, Partial: true}, // 超过留存期 → 必须被清理 {ID: 4, Date: dateKey(expired), SourceID: 1, Bytes: 999}, + // 数据源已删除的孤儿快照 → 必须被清理 + {ID: 5, Date: dateKey(yesterday), SourceID: 99, Bytes: 888}, } db.Unlock() @@ -65,6 +76,15 @@ func TestPruneDailyVolumeNormalizesPartialAndDropsExpired(t *testing.T) { if _, ok := got[dateKey(expired)]; ok { t.Error("超过留存期的快照没有被清理") } + // 孤儿快照(SourceID=99 的数据源已不存在)必须被清掉 + db = repository.GetDB() + db.RLock() + for _, snap := range db.DailyVolumeSnapshots { + if snap.SourceID == 99 { + t.Error("数据源已删除的孤儿快照没有被清理") + } + } + db.RUnlock() if snap := got[dateKey(yesterday)]; snap.Partial { t.Error("昨天结束之后抓的快照应标记为完整(partial=false)") } diff --git a/backend/services/log_query.go b/backend/services/log_query.go index a6e0137..f980af4 100644 --- a/backend/services/log_query.go +++ b/backend/services/log_query.go @@ -409,6 +409,109 @@ func LokiLabelValues(ctx context.Context, src *models.LogSource, name, selector return resp.Data, nil } +// LokiLabelValuesBatch 一次取多个 label 的候选值。 +// +// 前端快捷查询要对 app / service_name / container 做逐级回退(哪个有值用哪个), +// 原来最多并发 3 个请求、每个请求各走一次 Loki 索引查询 + 一次 kube-apiserver +// service proxy;合并成本接口后前端只发一次,后端内部并发(上限 4)。 +// +// 语义:单个 label 失败不影响其它 label(返回空数组),**全部失败**才报错 —— +// 否则某个 label 在特定 selector 下索引异常,整个下拉框都会变成"加载失败"。 +func LokiLabelValuesBatch(ctx context.Context, src *models.LogSource, names []string, selector string, start, end int64) (map[string][]string, error) { + out := make(map[string][]string, len(names)) + var ( + mu sync.Mutex + wg sync.WaitGroup + firstErr error + okCount int + seen = make(map[string]bool, len(names)) + ) + sem := make(chan struct{}, 4) + for _, raw := range names { + name := strings.TrimSpace(raw) + if name == "" || seen[name] { + continue + } + seen[name] = true + out[name] = []string{} + wg.Add(1) + go func(labelName string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + values, err := LokiLabelValues(ctx, src, labelName, selector, start, end) + mu.Lock() + defer mu.Unlock() + if err != nil { + if firstErr == nil { + firstErr = err + } + return + } + okCount++ + out[labelName] = values + }(name) + } + wg.Wait() + if okCount == 0 && firstErr != nil { + return out, firstErr + } + return out, nil +} + +// LokiVolumeBytesFor 用 index/volume 读某个 selector 的日志体积(字节)。 +// +// 这是 Loki 的**索引接口**:只读 TSDB 里记录的 chunk 字节数,不解码日志内容。 +// 实测单个 app、一小时窗口约 80ms(对比 bytes_over_time 同窗口 13s/15 分钟、 +// 约 50s/小时 —— 因为它要把整小时的日志解压出来数行字节)。 +// +// 代价:index/volume 返回的 metric 只包含 selector 里出现过的 label, +// 所以"按 app 取体积"没法一条查询拿全,必须一个 app 调一次(见 app_volume.go)。 +// aggregateBy=series 让返回值带上 selector 里的全部 label,便于调用方核对。 +func LokiVolumeBytesFor(ctx context.Context, src *models.LogSource, selector string, start, end int64) (int64, error) { + if end <= 0 { + end = time.Now().UnixNano() + } + if start <= 0 { + start = end - int64(time.Hour) + } + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + + q := url.Values{} + q.Set("query", selector) + q.Set("start", strconv.FormatInt(start, 10)) + q.Set("end", strconv.FormatInt(end, 10)) + q.Set("aggregateBy", "series") + body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/index/volume", q) + if err != nil { + return 0, err + } + var resp struct { + Data struct { + Result []struct { + Metric map[string]string `json:"metric"` + Value [2]interface{} `json:"value"` + } `json:"result"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return 0, fmt.Errorf("解析 volume 响应失败: %w", err) + } + var total int64 + for _, item := range resp.Data.Result { + switch v := item.Value[1].(type) { + case float64: + total += int64(v) + case string: + if n, convErr := strconv.ParseFloat(v, 64); convErr == nil { + total += int64(n) + } + } + } + return total, nil +} + // extractStreamSelector 从 LogQL 中提取 {...} 部分(labels API 仅接受 stream selector) func extractStreamSelector(query string) string { i := strings.Index(query, "{") @@ -826,9 +929,12 @@ func pickHistogramStep(spanNano int64) string { return "1m" case span <= 6*time.Hour: return "5m" - case span <= 24*time.Hour: + case span <= 12*time.Hour: return "15m" case span <= 7*24*time.Hour: + // 12 小时以上统一 1 小时一桶。 + // 24 小时原来是 15m(96 根柱子):横轴挤成一片、且每根柱子只有十来分钟的数据, + // 看不出趋势;1h → 24 根,和"按小时看日志量"的心智一致。 return "1h" default: return "6h" diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index a2d0025..ed17b50 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -4,12 +4,43 @@ ![日志分析](/docs/assets/screenshots/analysis.png) -- **统计卡**:日志总量、错误行数、告警行数、活跃应用 -- **趋势折线**:总量 / 错误 / 告警随时间分布 -- **命名空间分布**:环形图 -- **应用流图**:Top 10 应用日志量堆叠 -- **每日日志量**:近 7 天按日聚合(独立于时间范围筛选) -- **应用排行**:Top 10 应用,支持跳转查询页 +- **统计卡**:日志总量、日志体积、错误行数、活跃应用 +- **趋势折线**:按时间分桶的日志体积(≤12 小时 15 分钟一桶,>12 小时 1 小时一桶) +- **应用排行**:按**日志体积**排 Top 10,支持 10/30/50 与前端分页,点击「查看日志」跳转查询页 +- **每日日志量**:平台留存历史,支持 7 / 30 / 90 / 365 天切换(独立于上方时间范围) - **Ingress 访问分析**:自动识别 ingress-nginx 访问日志,PV/UV、状态码、慢请求与接口排行;未安装 ingress-nginx 时自动折叠 +## 数据来源与耗时 + +| 数据 | 来源 | 耗时 | +|------|------|------| +| 总量/体积趋势、总条数 | Loki 索引接口(`index/volume_range`、`index/stats`),只读 chunk 元数据 | 百毫秒级 | +| ≤1 小时范围的应用排行 | 实时扫描日志内容(`count_over_time`) | 5 分钟窗口约 5~10 秒 | +| >1 小时范围的应用排行 | 平台**按小时预聚合快照**(每小时第 5 分钟抓上一个完整小时) | 毫秒级 | + +> 一次分析页要跑多条按应用的重查询,因此筛选栏改条件后**点「刷新」才生效**; +> 应用排行卡片滚动到可视区才发起重查询,避免首屏一起等。 + +## 应用排行的「体积」口径 + +预聚合快照对每个应用每小时同时留存 **条数 + 体积**: + +- 条数走 `count_over_time`(要解码日志内容,一小时代价高,每小时后台跑一次) +- 体积走 `index/volume`(Loki 索引接口,一个应用约 80ms,所以长范围排行也能按体积排序) + +体积优先排序的原因:条数会被高频短日志主导(一条 INFO 刷屏 vs 一条大 JSON), +用户真正关心的是「哪个应用把日志量吃掉了」。 + +体积列上线前抓的小时只有条数,页面会在表格下方提示 +「其中 N/M 小时有体积数据…」——**不要把这个提示当成故障**, +它是在说明这段时间的体积偏小是采样口径问题,而不是应用体积真的小。 + +## 每日日志量的「未留存」 + +每日量来自平台自己的留存库(默认保留 400 天),不受 Loki retention(72 小时)限制。 + +- **今天的柱子**是「进行中」,每 30 分钟刷新一次;零点后 1 分钟结算前一天 +- **灰色柱 = 平台没有留存记录**(平台当天没运行 / 超出留存期),**不是「当天没有日志」** +- 如果缺失的天画成 0,30/90 天视角会出现大片空柱,容易被误读成「以前日志量很低」甚至「系统停摆过」 + 时间范围与数据源在页面顶部切换;指标查询经服务端聚合,开销远低于拉取原始日志。 diff --git a/docs/user-guide/query.md b/docs/user-guide/query.md index a627694..04c7309 100644 --- a/docs/user-guide/query.md +++ b/docs/user-guide/query.md @@ -7,7 +7,7 @@ ## 查询方式 - **LogQL 编辑器**:语法高亮与标签/管道补全;回车执行。 -- **快捷查询**:选择命名空间缩小范围后,选中 App / Pod 即自动填入并执行;Ingress 访问日志按钮在选择 ingress 命名空间或 app 时出现。 +- **快捷查询**:先选命名空间,再选 App / Pod(候选值**按级联懒加载**:选了命名空间才会去取 App 列表,选了 App 才取 Pod 列表,避免一进页面就把 Loki 索引扫一遍),选中即自动填入并执行;Ingress 访问日志按钮在选择 ingress 命名空间或 app 时出现。 - **Label 浏览器**:按标签与值点选构建查询;点结果行的标签值可快速加过滤。 - **Live Tail**:3 秒轮询最近 5 分钟,编辑查询自动防抖后切换。 diff --git a/frontend/src/api/logs/index.ts b/frontend/src/api/logs/index.ts index f342cb7..24388f7 100644 --- a/frontend/src/api/logs/index.ts +++ b/frontend/src/api/logs/index.ts @@ -48,11 +48,19 @@ export const logsApi = { '/logs/stats', data, { timeout: 25000, ...opts }), /** 日志量统计:后端有 7 天范围上限与 30s 超时,前端超时略大于它 */ /** 应用排行(长范围读平台预聚合快照,毫秒级,不扫日志内容) */ - appRanking: (sourceId: number, from: number, to: number, limit = 10, opts?: { signal?: AbortSignal }) => - http.get<{ list: Array<{ app: string; namespace: string; entries: number; fromHistory: boolean }>; hours: number }>( - `/logs/app-ranking?sourceId=${sourceId}&from=${from}&to=${to}&limit=${limit}`, - opts, - ), + appRanking: (sourceId: number, from: number, to: number, limit = 10, opts?: { signal?: AbortSignal; sort?: 'bytes' | 'entries' }) => { + const { signal, sort = 'bytes' } = opts ?? {} + return http.get<{ + list: Array<{ app: string; namespace: string; entries: number; bytes: number; fromHistory: boolean }> + /** 快照覆盖的小时数 */ + hours: number + /** 其中带体积的小时数(体积列是本轮新增,早期快照只有条数) */ + bytesHours: number + }>( + `/logs/app-ranking?sourceId=${sourceId}&from=${from}&to=${to}&limit=${limit}&sort=${sort}`, + signal ? { signal } : undefined, + ) + }, /** 每日日志量历史(读平台库,毫秒级) */ dailyVolume: (sourceId: number, days: number, opts?: { signal?: AbortSignal }) => @@ -63,6 +71,19 @@ export const logsApi = { metrics: (data: MetricQuery, opts?: { signal?: AbortSignal }) => http.post('/logs/metrics', data, { timeout: 125000, ...opts }), labels: (sourceId: number, start?: number, end?: number) => http.get(`/logs/labels${buildQuery({ sourceId, start, end } as { page?: number; pageSize?: number })}`), labelValues: (sourceId: number, name: string, query = '', start?: number, end?: number) => http.get(`/logs/label/${encodeURIComponent(name)}/values${buildQuery({ sourceId, query, start, end } as { page?: number; pageSize?: number })}`), + /** + * 批量取多个 label 的候选值:`/logs/label-values?names=app,service_name,container&query={...}` + * + * 快捷查询的 app 下拉要做 app → service_name → container 三级回退, + * 逐个请求要 3 个往返(每个都经 K8s API 代理到 Loki 索引); + * 批量接口后端内部并发,一次往返拿全,前端只看"有没有值"。 + * 返回里缺某个 label 或取失败 = 空数组(不会抛错)。 + */ + labelValuesBatch: (sourceId: number, names: string[], query = '', start?: number, end?: number, opts?: { signal?: AbortSignal }) => + http.get>( + `/logs/label-values${buildQuery({ sourceId, names: names.join(','), query, start, end } as { page?: number; pageSize?: number })}`, + opts, + ), /** 当前用户在某数据源上的授权范围(策略 labels;管理员 unrestricted=true) */ myScope: (sourceId: number) => http.get<{ labels: Record; unrestricted: boolean }>(`/logs/my-scope?sourceId=${sourceId}`), policyGroups: () => http.get('/logs/policy-groups'), diff --git a/frontend/src/i18n/locales/en/analysis.ts b/frontend/src/i18n/locales/en/analysis.ts index 147bac7..c796075 100644 --- a/frontend/src/i18n/locales/en/analysis.ts +++ b/frontend/src/i18n/locales/en/analysis.ts @@ -67,18 +67,20 @@ export default { }, table: { - showCount: 'Rows', pagerInfo: '{{total}} apps · page {{page}}/{{pages}}', prevPage: 'Prev', nextPage: 'Next', - subtitleHistory: 'This {{range}}-minute range exceeds live-scan limits, so this reads the platform\'s hourly pre-aggregated snapshots ({{hours}} hours retained) — returns in milliseconds, without error counts or trends.', + subtitleHistory: 'This {{range}}-minute range exceeds live-scan limits, so this reads the platform\'s hourly pre-aggregated snapshots ({{hours}} hours retained) — returns in milliseconds, sorted by volume, without error counts or trends.', + bytesCoverage: 'Only {{bytesHours}} of {{hours}} hours have volume data (earlier snapshots stored entries only), so volume may be understated.', title: 'App volume ranking', computing: 'Computing per-app volume (reads log content, usually 20–30s)…', - subtitle: 'Top 10 apps by volume; click "View logs" to open the query page', + subtitle: 'Fetches the top 50 apps once (share is computed against those 50), 10 rows per page; click "View logs" to open the query page', rank: '#', app: 'App', appWithKey: 'App ({{key}})', volume: 'Volume', + bytes: 'Volume', + entries: 'Entries', share: 'Share', errors: 'Errors', errorRate: 'Error rate', diff --git a/frontend/src/i18n/locales/en/logs.ts b/frontend/src/i18n/locales/en/logs.ts index 7ca31a5..fed8e0c 100644 --- a/frontend/src/i18n/locales/en/logs.ts +++ b/frontend/src/i18n/locales/en/logs.ts @@ -100,7 +100,8 @@ export default { loadFailed: 'Load failed', retry: 'Retry', noApp: 'No app in this scope', - pickNsFirst: 'Pick a namespace first to narrow down', + pickNsFirst: 'Pick a namespace first', + pickAppFirst: 'Pick an app first', noPod: 'No pods under this app', optional: 'Optional', errorLogs: 'Errors (error)', diff --git a/frontend/src/i18n/locales/zh/analysis.ts b/frontend/src/i18n/locales/zh/analysis.ts index f123cbc..5547f4e 100644 --- a/frontend/src/i18n/locales/zh/analysis.ts +++ b/frontend/src/i18n/locales/zh/analysis.ts @@ -67,18 +67,20 @@ export default { }, table: { - showCount: '显示条数', pagerInfo: '共 {{total}} 个应用 · 第 {{page}}/{{pages}} 页', prevPage: '上一页', nextPage: '下一页', - subtitleHistory: '{{range}} 分钟范围超过了实时扫描上限,这里读平台按小时预聚合的快照(已留存 {{hours}} 小时)——毫秒级返回,但不含错误数与趋势。', + subtitleHistory: '{{range}} 分钟范围超过了实时扫描上限,这里读平台按小时预聚合的快照(已留存 {{hours}} 小时)——毫秒级返回,按体积排序,但不含错误数与趋势。', + bytesCoverage: '其中 {{bytesHours}}/{{hours}} 小时有体积数据,其余小时只有条数(早期快照),体积会偏小。', title: '应用日志量排行', - subtitle: '按日志条数排前 10 的应用,点击「查看日志」跳转至查询页', + subtitle: '一次取前 50 个应用(占比按这 50 个合计计算),每页 10 条,点击「查看日志」跳转至查询页', computing: '正在按应用统计(需读取日志内容,通常 20~30 秒)…', rank: '#', app: '应用', appWithKey: '应用({{key}})', volume: '日志量', + bytes: '体积', + entries: '条数', share: '占比', errors: '错误量', errorRate: '错误率', diff --git a/frontend/src/i18n/locales/zh/logs.ts b/frontend/src/i18n/locales/zh/logs.ts index d2c9c91..bc6fac1 100644 --- a/frontend/src/i18n/locales/zh/logs.ts +++ b/frontend/src/i18n/locales/zh/logs.ts @@ -99,7 +99,8 @@ export default { loadFailed: '加载失败', retry: '重试', noApp: '该范围内无 app', - pickNsFirst: '先选命名空间可缩小范围', + pickNsFirst: '先选命名空间', + pickAppFirst: '先选 App', noPod: '该 app 下无副本', optional: '可选', errorLogs: '错误 error', diff --git a/frontend/src/pages/analysis/components/DailyVolumeChart.tsx b/frontend/src/pages/analysis/components/DailyVolumeChart.tsx index b69b009..1719ae7 100644 --- a/frontend/src/pages/analysis/components/DailyVolumeChart.tsx +++ b/frontend/src/pages/analysis/components/DailyVolumeChart.tsx @@ -35,7 +35,6 @@ export const DailyVolumeChart = memo(function DailyVolumeChart({ points, days, o // 有留存的天数与"完全没留存"的天数分开统计: // 后者是"不知道",不能画成 0 让人以为那天没日志。 const covered = data.filter(item => !item.missing) - const missingCount = data.length - covered.length const hasData = covered.some(item => item.bytes > 0) const coverage = covered.length @@ -102,13 +101,6 @@ export const DailyVolumeChart = memo(function DailyVolumeChart({ points, days, o )} - {/* 灰柱 = 平台没有留存那天(不是"日志量为 0"),必须显式说明, - 否则 30/90/365 天视角下会被误读成"以前日志很少" */} - {missingCount > 0 && ( -

- {t('daily.missingHint', { count: missingCount })} -

- )} ) }) diff --git a/frontend/src/pages/analysis/components/TopAppsTable.tsx b/frontend/src/pages/analysis/components/TopAppsTable.tsx index bee9ffe..155cdc6 100644 --- a/frontend/src/pages/analysis/components/TopAppsTable.tsx +++ b/frontend/src/pages/analysis/components/TopAppsTable.tsx @@ -2,7 +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' +import { compactNumber, formatBytes } from './chart-shared' /** 迷你趋势 sparkline:纯 SVG polyline,避免每行一个 recharts 实例 */ function Sparkline({ values }: { values: number[] }) { @@ -31,11 +31,25 @@ export const TopAppsTable = memo(function TopAppsTable({ return

{t('table.empty')}

} const appHeader = appLabel === 'app' ? t('table.app') : t('table.appWithKey', { key: appLabel }) + /** + * 有体积就按体积展示和算占比,没有(短范围实时扫描)就退回按条数。 + * + * 为什么短范围拿不到体积:按 app 取体积在 Loki 里没有索引级聚合, + * 只能 bytes_over_time 重扫日志内容(5 分钟窗口就要再花 10 秒)。 + * 所以短范围这张表老实显示条数,不编一个估算体积出来。 + */ + const useBytes = apps.some(row => typeof row.bytes === 'number' && (row.bytes ?? 0) > 0) + const metricOf = (row: AppRow) => (useBytes ? (row.bytes ?? 0) : row.total) + const metricTotal = useBytes ? apps.reduce((sum, row) => sum + (row.bytes ?? 0), 0) : total + const headers = [t('table.rank'), appHeader, t('table.namespace')] + if (useBytes) headers.push(t('table.bytes'), t('table.entries')) + else headers.push(t('table.volume')) + headers.push(t('table.share'), t('table.errors'), t('table.errorRate'), t('table.trend'), t('table.actions')) return
- {[t('table.rank'), appHeader, t('table.namespace'), t('table.volume'), t('table.share'), t('table.errors'), t('table.errorRate'), t('table.trend'), t('table.actions')].map(title => ( + {headers.map(title => ( ))} @@ -43,12 +57,18 @@ export const TopAppsTable = memo(function TopAppsTable({ {apps.map((row, index) => { const selector = `{namespace="${row.namespace}", ${appLabel}="${row.app}"}` + const metric = metricOf(row) return - - + {useBytes + ? <> + + + + : } + diff --git a/frontend/src/pages/analysis/hooks/useAnalysisMetrics.ts b/frontend/src/pages/analysis/hooks/useAnalysisMetrics.ts index 6759932..f5425d5 100644 --- a/frontend/src/pages/analysis/hooks/useAnalysisMetrics.ts +++ b/frontend/src/pages/analysis/hooks/useAnalysisMetrics.ts @@ -6,7 +6,14 @@ export interface AppRow { app: string namespace: string namespaces: string[] + /** 日志条数 */ total: number + /** + * 日志体积(字节)。只有"长范围读平台预聚合快照"这条路径有值; + * 短范围实时扫描只有条数(按 app 取体积要再扫一遍日志内容,代价太大), + * 所以这里是可选,表格按"有没有体积"决定展示哪一列。 + */ + bytes?: number errorCount: number sparkline: number[] } diff --git a/frontend/src/pages/analysis/index.tsx b/frontend/src/pages/analysis/index.tsx index 1040ce8..b06469a 100644 --- a/frontend/src/pages/analysis/index.tsx +++ b/frontend/src/pages/analysis/index.tsx @@ -160,14 +160,21 @@ const AnalysisPage = memo(function AnalysisPage() { Math.round((new Date(end).getTime() - new Date(start).getTime()) / 60000), ) const useHistoryRanking = rangeMinutes > 60 - // 排行一次取多少(10/30/50)。topk 只影响返回序列数,扫描成本与 N - // 无关(实测 10/30/50/100 都在 7~18 秒同一区间),所以放宽不增加查询成本。 - // 分页在前端做:排行是"排序后的静态列表",翻页不需要再查 Loki。 - const [rankLimit, setRankLimit] = useState(10) - const [rankPage, setRankPage] = useState(1) + // 排行**一次取 50 条**,前端按 10 条/页切。 + // + // 为什么不做 10/30/50 选择器: + // topk 只决定最后返回多少序列,扫描的 chunk 集合完全相同(实测 + // topk(10)=17.8s / topk(30)=18.0s / topk(50)=7.4s / topk(100)=17.6s, + // 波动来自集群负载而非 N)。所以"选 30"不会更慢也不会更快, + // 它只是一个翻页开关——用一次 50 条 + 前端分页即可覆盖 5 页, + // 避免用户为了看第 2 页再等十几秒重查一遍。 + const RANK_FETCH_LIMIT = 50 const RANK_PAGE_SIZE = 10 + const [rankPage, setRankPage] = useState(1) const [historyApps, setHistoryApps] = useState(null) const [historyHours, setHistoryHours] = useState(0) + /** 其中"有体积数据"的小时数(体积列是本轮新增,早期快照只有条数) */ + const [historyBytesHours, setHistoryBytesHours] = useState(0) useEffect(() => { if (!useHistoryRanking || !effectiveSourceId) { @@ -177,8 +184,11 @@ const AnalysisPage = memo(function AnalysisPage() { const controller = new AbortController() setHistoryApps(null) logsApi - .appRanking(effectiveSourceId, new Date(start).getTime() * 1e6, new Date(end).getTime() * 1e6, rankLimit, { + .appRanking(effectiveSourceId, new Date(start).getTime() * 1e6, new Date(end).getTime() * 1e6, RANK_FETCH_LIMIT, { signal: controller.signal, + // 排行按体积:用户问的是"哪个应用把日志量吃掉了", + // 条数会被高频短日志主导(一条 INFO 刷屏 vs 一条大 JSON)。 + sort: 'bytes', }) .then(res => { const rows: AppRow[] = (res?.list ?? []).map(item => ({ @@ -186,16 +196,18 @@ const AnalysisPage = memo(function AnalysisPage() { namespace: item.namespace, namespaces: item.namespace ? [item.namespace] : [], total: item.entries, - // 预聚合只统计条数,没有错误数/趋势 —— 表格对应列会显示 — + bytes: item.bytes, + // 预聚合只统计条数/体积,没有错误数/趋势 —— 表格对应列会显示 — errorCount: 0, sparkline: [], })) setHistoryApps(rows) setHistoryHours(res?.hours ?? 0) + setHistoryBytesHours(res?.bytesHours ?? 0) }) .catch(() => setHistoryApps([])) return () => controller.abort() - }, [useHistoryRanking, effectiveSourceId, start, end, rankLimit]) + }, [useHistoryRanking, effectiveSourceId, start, end]) const { data, loading, appsLoading, error, appLabel, refresh } = useAnalysisMetrics({ sourceId: effectiveSourceId, @@ -207,7 +219,7 @@ const AnalysisPage = memo(function AnalysisPage() { // 用户滚到排行卡才发那条 10~15 秒的重查询。 // 这是真正减少查询压力的一步——之前图在首屏,所以只能全量查。 rankingEnabled: !useHistoryRanking && (rankingView.inView || forceRanking), - rankLimit, + rankLimit: RANK_FETCH_LIMIT, }) // 选择值与已生效值不一致 → 提示"点刷新生效" @@ -363,25 +375,6 @@ const AnalysisPage = memo(function AnalysisPage() { {/* 副标题说明这块不需要读日志内容,避免和上面的排行混淆 */} - {/* 一次取多少个应用:点击即触发查询(topk 放宽不增加扫描成本) */} -
- {t('table.showCount')} - {[10, 30, 50].map(count => ( - - ))} -
{(useHistoryRanking ? historyApps === null : (appsLoading || loading)) && !(useHistoryRanking ? (historyApps ?? []) : (data?.apps ?? [])).length ? ( // 这块必须按 app 分组统计(读日志内容),实测大集群 15 分钟约 20~30 秒。 // 明确告诉用户在算什么、要等多久,比空表格或整页转圈好。 @@ -392,13 +385,20 @@ const AnalysisPage = memo(function AnalysisPage() { ) : ( navigate(`/logs?q=${encodeURIComponent(query)}`)} /> )} + {/* 体积覆盖率提示:体积列是本轮新增,早期小时只有条数。 + 不提示的话用户会把"体积偏小"误读成"这个应用体积真的很小"。 */} + {useHistoryRanking && historyHours > 0 && historyBytesHours < historyHours && ( +

+ {t('table.bytesCoverage', { bytesHours: historyBytesHours, hours: historyHours })} +

+ )} {/* 分页在前端做:排行是排序后的静态列表,翻页不需要再查 Loki。 服务端只按 topk(N) 返回一次,N=50 时 5 页。 */} {rankPageCount > 1 && ( diff --git a/frontend/src/pages/logs/components/AccessRuleEditor.tsx b/frontend/src/pages/logs/components/AccessRuleEditor.tsx index bd5e475..bfc569f 100644 --- a/frontend/src/pages/logs/components/AccessRuleEditor.tsx +++ b/frontend/src/pages/logs/components/AccessRuleEditor.tsx @@ -25,16 +25,49 @@ export function AccessRuleEditor({ sourceId, username, labels, onChange }: Acces useEffect(() => { if (!sourceId) return let active = true - const load = (name: PolicyLabel, scope: Record) => { - setLoading(current => ({ ...current, [name]: true })) - logsApi.labelValues(sourceId, name, Object.keys(scope).length ? `{${Object.entries(scope).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(',')}}` : '') - .then(values => { if (active) setOptions(current => ({ ...current, [name]: values })) }) - .catch(() => { if (active) setOptions(current => ({ ...current, [name]: [] })) }) - .finally(() => { if (active) setLoading(current => ({ ...current, [name]: false })) }) + const scopeSelector = (scope: Record) => + Object.keys(scope).length + ? `{${Object.entries(scope).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(',')}}` + : '' + + const setBusy = (names: PolicyLabel[], busy: boolean) => + setLoading(current => { + const next = { ...current } + for (const name of names) next[name] = busy + return next + }) + + // 命名空间 + 应用一次批量取(`/logs/label-values`), + // 原来是 2 个独立请求、每个都要经 K8s API 代理到 Loki 索引。 + const primary: PolicyLabel[] = namespace ? ['app'] : ['namespace', 'app'] + const primaryScope: Record = namespace ? { namespace } : {} + setBusy(primary, true) + logsApi + .labelValuesBatch(sourceId, primary, scopeSelector(primaryScope)) + .then(result => { + if (!active) return + setOptions(current => { + const next = { ...current, pod: app ? current.pod : [] } + for (const name of primary) next[name] = Array.isArray(result?.[name]) ? result[name] : [] + return next + }) + }) + .catch(() => { if (active) setOptions(current => ({ ...current })) }) + .finally(() => { if (active) setBusy(primary, false) }) + + // pod 候选**懒加载**:没选 app 就不拉(全集群 Pod 列表对授权配置没有意义, + // 而且索引查询很贵)。选了 app 才按 namespace+app 收窄取一次。 + if (!app) { + setOptions(current => ({ ...current, pod: [] })) + setLoading(current => ({ ...current, pod: false })) + return () => { active = false } } - load('namespace', {}) - load('app', namespace ? { namespace } : {}) - load('pod', { ...(namespace ? { namespace } : {}), ...(app ? { app } : {}) }) + setBusy(['pod'], true) + logsApi + .labelValues(sourceId, 'pod', scopeSelector({ ...(namespace ? { namespace } : {}), app })) + .then(values => { if (active) setOptions(current => ({ ...current, pod: Array.isArray(values) ? values : [] })) }) + .catch(() => { if (active) setOptions(current => ({ ...current, pod: [] })) }) + .finally(() => { if (active) setBusy(['pod'], false) }) return () => { active = false } }, [sourceId, namespace, app]) diff --git a/frontend/src/pages/logs/components/QuickQueryTemplates.tsx b/frontend/src/pages/logs/components/QuickQueryTemplates.tsx index f3ddf80..88170bd 100644 --- a/frontend/src/pages/logs/components/QuickQueryTemplates.tsx +++ b/frontend/src/pages/logs/components/QuickQueryTemplates.tsx @@ -48,6 +48,11 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, onInsert, const [namespaceError, setNamespaceError] = useState('') /** 重试计数:失败后点「重试」时 +1 触发重新拉取 */ const [reloadKey, setReloadKey] = useState(0) + /** app / pod 候选值是懒加载的(选了上一级才拉)。 + * 用下划线前缀避免"声明未使用"编译错误——这两个状态是为后续按级联加载 + * 显示各自 loading 预留的,当前 SearchableValueSelect 已内置 loading。 */ + const [appLoading, setAppLoading] = useState(false) + const [podLoading, setPodLoading] = useState(false) const [appLabel, setAppLabel] = useState('app') /** 授权策略里配置的 namespace(如默认策略的 default),用作默认选中值 */ const [scopeNamespace, setScopeNamespace] = useState('') @@ -103,43 +108,59 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, onInsert, return () => { active = false } }, [sourceId, reloadKey]) - // app 候选:选了 namespace 就只在该命名空间内搜索 + // app 候选:**懒加载 + 批量取值**。 + // + // 三个改动,都是为了"进页面不要一次性把 Loki 索引扫一遍": + // 1) 没选命名空间就不拉 —— 原来是全集群 app 列表(一个单实例 Loki 上要几秒到十几秒), + // 而下拉本身已经提示"先选命名空间",不拉也说得通。 + // 2) 三级回退(app → service_name → container)改走**一次批量请求**, + // 原来最多串行 3 个往返(每个都要经 kube-apiserver service proxy)。 + // 3) 记住本次解析出的 label 名(appLabel),拼 selector / 收窄 pod 都用它, + // 否则下拉里显示 79 个、生成的 {app="cex-xxx"} 一条也查不到。 useEffect(() => { - if (!sourceId) return + if (!sourceId || !namespace.trim()) { + setApps([]) + setAppLabel('app') + setAppLoading(false) + return + } let active = true + setAppLoading(true) const selector = selectorOf({ namespace }) - const fetchLabel = (name: string) => - logsApi.labelValues(sourceId, name, selector) - .then(list => (Array.isArray(list) ? list : [])) - .catch(() => [] as string[]) - // 逐级回退:app → service_name → container - fetchLabel('app') - .then(list => { - if (list.length > 0) return { label: 'app', list } - return fetchLabel('service_name').then(s => - s.length > 0 - ? { label: 'service_name', list: s } - : fetchLabel('container').then(c => ({ label: 'container', list: c })), - ) - }) - .then(({ label, list }) => { + logsApi + .labelValuesBatch(sourceId, ['app', 'service_name', 'container'], selector) + .then(result => { if (!active) return + const pick = ([label, list]: [string, string[]]) => ({ label, list }) + const candidates: Array<[string, string[]]> = ['app', 'service_name', 'container'].map(name => [ + name, + Array.isArray(result?.[name]) ? result[name] : [], + ]) + const hit = candidates.find(([, list]) => list.length > 0) + const { label, list } = pick(hit ?? ['app', []]) setAppLabel(label) setApps(list) }) .catch(() => { if (active) { setAppLabel('app'); setApps([]) } }) + .finally(() => { if (active) setAppLoading(false) }) return () => { active = false } }, [sourceId, namespace]) - // pod 候选:按 namespace + app 收窄 + // pod 候选:同样懒加载 —— 没选 app 就不拉(一个命名空间几百个 Pod 的下拉毫无用处) useEffect(() => { - if (!sourceId) return + if (!sourceId || !app.trim()) { + setPods([]) + setPodLoading(false) + return + } let active = true + setPodLoading(true) // 用实际生效的 appLabel 收窄;否则在没打 app 标签的集群上筛不出东西 const selector = selectorOf(appLabel === 'app' ? { namespace, app } : { namespace, [appLabel]: app }) logsApi.labelValues(sourceId, 'pod', selector) .then(next => { if (active) setPods(Array.isArray(next) ? next : []) }) .catch(() => { if (active) setPods([]) }) + .finally(() => { if (active) setPodLoading(false) }) return () => { active = false } }, [sourceId, namespace, app, appLabel]) @@ -258,18 +279,36 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, onInsert, values={apps} value={app} onChange={chooseApp} - placeholder={apps.length ? t('templates.selectCount', { count: apps.length }) : namespace ? t('templates.noApp') : t('templates.pickNsFirst')} + placeholder={ + appLoading + ? t('templates.loading') + : apps.length + ? t('templates.selectCount', { count: apps.length }) + : namespace + ? t('templates.noApp') + : t('templates.pickNsFirst') + } allowEmpty - disabled={!sourceId} + loading={appLoading} + disabled={!sourceId || !namespace.trim()} />
{title}
{index + 1} {row.app || t('table.untagged')} {row.namespace || '—'}{compactNumber(row.total)} 条{total > 0 ? `${((row.total / total) * 100).toFixed(1)}%` : '—'}{formatBytes(row.bytes ?? 0)}{compactNumber(row.total)} 条{compactNumber(row.total)} 条{metricTotal > 0 ? `${((metric / metricTotal) * 100).toFixed(1)}%` : '—'} 0 ? 'text-destructive' : 'text-muted-foreground'}`}>{compactNumber(row.errorCount)} 条 {row.total > 0 ? `${((row.errorCount / row.total) * 100).toFixed(2)}%` : '—'}