From d0adc095dd38ebb80c50cda508eba78e31c25bed Mon Sep 17 00:00:00 2001 From: opsx Date: Fri, 30 Oct 2026 16:35:35 +0000 Subject: [PATCH] =?UTF-8?q?feat(analysis):=20=E9=95=BF=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E6=8E=92=E8=A1=8C=E6=94=B9=E8=B5=B0=E3=80=8C?= =?UTF-8?q?=E5=B0=8F=E6=97=B6=E7=BA=A7=E9=A2=84=E8=81=9A=E5=90=88=E3=80=8D?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E6=89=AB=E6=97=A5=E5=BF=97=E5=86=85?= =?UTF-8?q?=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit === 问题(实测)=== 按应用排行必须读日志内容(Loki 3.4 的 index/volume 不支持 app 维度聚合, aggregateBy=app 无效),成本 ∝ 时间范围: 5 分钟 ≈ 1.3GB → 9.8s 15 分钟 ≈ 3.9GB → 19.5s 1 小时 ≈ 15GB → 预计 60~90s 6 小时 ≈ 92GB → 5~8 分钟 24 小时 ≈ 370GB → 不可用 用户在时间范围里选 6h/24h 时,页面必然卡死。 === 方案 === 新增 models.AppVolumeSnapshot + services/app_volume.go: - 后台任务每小时第 5 分钟抓「上一个完整小时」的 per-app 条数 - 用**瞬时查询**(/loki/api/v1/query + [1h])而不是 query_range: 单点评估、扫描量相同、省掉多步开销;并绕开 LokiMetrics 的 90s 上限 与重型查询信号量(后台任务不该和前台抢令牌) - 每源每小时只留 top 50 应用,保留 90 天 - 幂等:同 (hour, source, ns, app) 更新而非重复插入 - 超出 Loki 26h 保留期的窗口跳过(抓也抓不到,白等几分钟) 新增 GET /api/v1/logs/app-ranking?sourceId&from&to&limit → 读库毫秒级 前端:analysis 判断范围 > 60 分钟就走预聚合接口,并停掉实时重查询 (rankingEnabled 置 false);表格副标题说明「读的是预聚合快照,已留存 N 小时, 不含错误数与趋势」。 === 顺带修掉的根因 bug === useInView 原来用 useRef + useEffect([inView]):分析页首屏 data===null 时 内容块不渲染,ref.current 为 null,effect 直接 return;等数据到了 DOM 才挂上, 但依赖没变 → effect 不重跑 → IntersectionObserver 从未创建 → inView 永远 false → 重查询永不发出 → 页面一直显示"没有应用维度的数据"。 改成 callback ref(节点挂载即作为依赖),并加 scroll/resize 兜底 + 挂载瞬间量一次。 另外分析页加 8 秒无条件兜底:可见性判定再失灵,8 秒后也一定查一次。 默认时间范围 15 分钟 → 5 分钟(19.5s → 9.8s)。 === 实测 === Pod = Hub latest = 98de87e7f895 5 分钟窗口 by-app:9.8s(75 序列);15 分钟:19.5s --- backend/controllers/logs.go | 23 ++ backend/main.go | 3 + backend/models/app_volume.go | 33 +++ backend/repository/db.go | 9 + backend/routers/router.go | 2 + backend/services/app_volume.go | 279 +++++++++++++++++++++++ frontend/src/api/logs/index.ts | 7 + frontend/src/hooks/useInView.ts | 78 ++++--- frontend/src/i18n/locales/en/analysis.ts | 1 + frontend/src/i18n/locales/zh/analysis.ts | 1 + frontend/src/pages/analysis/index.tsx | 71 +++++- 11 files changed, 467 insertions(+), 40 deletions(-) create mode 100644 backend/models/app_volume.go create mode 100644 backend/services/app_volume.go diff --git a/backend/controllers/logs.go b/backend/controllers/logs.go index 2147e93..2d2c6ae 100644 --- a/backend/controllers/logs.go +++ b/backend/controllers/logs.go @@ -330,6 +330,29 @@ func (ctrl *LogsController) Health(c *gin.Context) { utils.Success(c, gin.H{"ready": services.LokiReady(ctx, src)}) } +// AppRanking 按应用排行(长范围走平台预聚合,毫秒级;短范围由前端走实时查询)。 +func (ctrl *LogsController) AppRanking(c *gin.Context) { + sourceID, err := strconv.ParseUint(c.Query("sourceId"), 10, 64) + if err != nil || sourceID == 0 { + utils.BadRequest(c, "sourceId 无效") + return + } + from, err1 := strconv.ParseInt(c.DefaultQuery("from", "0"), 10, 64) + to, err2 := strconv.ParseInt(c.DefaultQuery("to", "0"), 10, 64) + if err1 != nil || err2 != nil || from <= 0 || to <= 0 || to <= from { + utils.BadRequest(c, "时间范围无效(from/to 需为纳秒时间戳且 to > from)") + return + } + limit := 10 + if raw := c.Query("limit"); raw != "" { + if v, convErr := strconv.Atoi(raw); convErr == nil && v > 0 && v <= 100 { + 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))}) +} + // DailyVolume 每日日志量历史(平台留存,不受 Loki retention 限制)。 // // query: sourceId, days(默认 7,最长 400) diff --git a/backend/main.go b/backend/main.go index fbef7c3..df242b6 100644 --- a/backend/main.go +++ b/backend/main.go @@ -86,6 +86,9 @@ func main() { services.CleanupOrphanManagedLogSources() // 异步重新探测运行中部署的数据源连接方式(平台/Loki 可能在主机或集群内) go services.EnsureLogSourcesForRunningDeployments() + // 按应用日志量的**小时级预聚合**:每小时抓上一个完整小时, + // 让 6h/24h 这类长范围的"应用排行"变成读库(毫秒级),不扫日志内容。 + go services.StartAppVolumeCapture() // 补齐/升级内置告警规则(幂等):启动即执行,存量环境也能拿到「默认启用+绑默认通道」 seed.SeedAlertRules() diff --git a/backend/models/app_volume.go b/backend/models/app_volume.go new file mode 100644 index 0000000..b4bb6c0 --- /dev/null +++ b/backend/models/app_volume.go @@ -0,0 +1,33 @@ +package models + +import "time" + +// AppVolumeSnapshot 按小时留存「每个应用」的日志量(条数)。 +// +// 为什么必须落库: +// +// Loki 的 index/volume 只支持 namespace 级聚合(3.4 实测 aggregateBy=app 不生效), +// 所以"按应用排行"只能读日志内容,成本 ∝ 时间范围: +// 5 分钟 ≈ 1.3GB ≈ 10 秒 +// 15 分钟 ≈ 3.9GB ≈ 20 秒 +// 6 小时 ≈ 92GB ≈ 5~8 分钟 ← 页面根本等不了 +// 24 小时 ≈ 370GB ≈ 不可用 +// 把 per-app 数据按小时在后台算好存起来,长范围排行就变成读库(毫秒级)。 +// +// 采集方式:每小时第 5 分钟抓「上一个完整小时」,一条 +// +// sum by (app, namespace)(count_over_time({...}[1h])) +// +// 查询在后台跑,慢一点无所谓;页面永远读库。 +type AppVolumeSnapshot struct { + ID uint `json:"id"` + // Hour 小时桶,格式 YYYY-MM-DDTHH(本地时区,按整点对齐) + Hour string `json:"hour"` + SourceID uint `json:"sourceId"` + Namespace string `json:"namespace"` + App string `json:"app"` + Entries int64 `json:"entries"` + // Partial 该小时数据是否不完整(如平台停了一段时间后才回补,超出 Loki 保留期) + Partial bool `json:"partial"` + CapturedAt time.Time `json:"capturedAt"` +} diff --git a/backend/repository/db.go b/backend/repository/db.go index 223e04d..7ac8d8d 100644 --- a/backend/repository/db.go +++ b/backend/repository/db.go @@ -42,6 +42,8 @@ type JsonDB struct { AuditLogs []models.AuditLog `json:"auditLogs"` // DailyVolumeSnapshots 每日日志量历史(Loki retention 只有 24h,历史必须自己留) DailyVolumeSnapshots []models.DailyVolumeSnapshot `json:"dailyVolumeSnapshots"` + // AppVolumeSnapshots 按小时留存的 per-app 日志量(长范围排行读这个,不扫日志内容) + AppVolumeSnapshots []models.AppVolumeSnapshot `json:"appVolumeSnapshots"` // SchemaVersion 数据结构版本号。 // 之前加字段全靠 JSON 反序列化的零值兜底,字段一多必然出事 @@ -612,6 +614,7 @@ func (d *JsonDB) Reload() error { d.LogPolicyGroups = fresh.LogPolicyGroups d.AuditLogs = fresh.AuditLogs d.DailyVolumeSnapshots = fresh.DailyVolumeSnapshots + d.AppVolumeSnapshots = fresh.AppVolumeSnapshots d.SchemaVersion = fresh.SchemaVersion d.sequences = map[string]uint{} for _, u := range d.Users { @@ -684,6 +687,11 @@ func (d *JsonDB) Reload() error { d.sequences["dailyVolumeSnapshots"] = v.ID } } + for _, v := range d.AppVolumeSnapshots { + if v.ID > d.sequences["appVolumeSnapshots"] { + d.sequences["appVolumeSnapshots"] = v.ID + } + } return nil } @@ -851,6 +859,7 @@ func InitDB() error { "alertDeliveries": 0, "auditLogs": 0, "dailyVolumeSnapshots": 0, + "appVolumeSnapshots": 0, }, saveChan: make(chan saveRequest, 10), stopChan: make(chan struct{}), diff --git a/backend/routers/router.go b/backend/routers/router.go index 29b2bfb..1bcd852 100644 --- a/backend/routers/router.go +++ b/backend/routers/router.go @@ -104,6 +104,8 @@ func SetupRouter() *gin.Engine { logs.POST("/histogram", logsCtrl.Histogram) logs.GET("/histogram", logsCtrl.Histogram) logs.GET("/daily-volume", logsCtrl.DailyVolume) + // 应用排行:长范围读平台预聚合快照(毫秒级),不扫日志内容 + logs.GET("/app-ranking", logsCtrl.AppRanking) // 总量统计(index/stats):只读索引,用于算总页数 logs.POST("/stats", logsCtrl.Stats) logs.POST("/metrics", middleware.AdminAuth(), logsCtrl.Metrics) diff --git a/backend/services/app_volume.go b/backend/services/app_volume.go new file mode 100644 index 0000000..0384131 --- /dev/null +++ b/backend/services/app_volume.go @@ -0,0 +1,279 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/url" + "sort" + "strconv" + "time" + + "lokistack/models" + "lokistack/repository" +) + +// 按应用的日志量:**小时级预聚合**。 +// +// 背景:按应用排行必须读日志内容(Loki 3.4 的 index/volume 不支持 app 维度聚合), +// 成本 ∝ 时间范围 —— 5 分钟 10 秒、15 分钟 20 秒、6 小时要 5~8 分钟、24 小时不可用。 +// +// 方案:后台每小时把「上一个完整小时」的 per-app 条数算好落库; +// 页面查 6h/24h/7d 时直接读库(毫秒级),不再扫日志。 +// 前台只在「≤1 小时」的短范围走实时查询(那种范围本来就快)。 + +const ( + // appVolumeRetentionDays 小时级 per-app 快照保留天数 + appVolumeRetentionDays = 90 + // appVolumeSampleTopN 每个小时只保留条数最多的 N 个应用,避免快照爆炸 + appVolumeSampleTopN = 50 + // appVolumeTimeout 单次抓取的超时(1 小时窗口 ≈ 15GB,给足时间) + appVolumeTimeout = 5 * time.Minute +) + +// AppRankRow 排行结果的一行 +type AppRankRow struct { + App string `json:"app"` + Namespace string `json:"namespace"` + Entries int64 `json:"entries"` + // FromHistory 该行来自平台预聚合(而不是实时扫日志) + FromHistory bool `json:"fromHistory"` +} + +// hourKey 把时间对齐到整点,格式 YYYY-MM-DDTHH(本地时区,与快照一致) +func hourKey(t time.Time) string { + return t.Local().Format("2006-01-02T15") +} + +// CaptureAppVolumeForHour 抓取指定小时(整点到整点)的 per-app 条数。 +func CaptureAppVolumeForHour(src *models.LogSource, hour time.Time) error { + if src == nil { + return fmt.Errorf("数据源为空") + } + start := hour.Truncate(time.Hour) + end := start.Add(time.Hour) + key := hourKey(start) + + ctx, cancel := context.WithTimeout(context.Background(), appVolumeTimeout) + defer cancel() + + // 用**瞬时查询**而不是 query_range: + // - 只评估一个时间点(窗口覆盖整个小时),扫描量与 query_range 相同 + // 但省掉多步评估的开销 + // - 直接走 lokiProxyGET + 自己 5 分钟超时,绕开 LokiMetrics 的 90 秒 + // 上限与重型查询信号量(后台任务不该和前台查询抢令牌) + query := fmt.Sprintf(`sum by (app, namespace) (count_over_time({namespace=~".+", app!="loki"}[%dm]))`, int(time.Hour.Minutes())) + q := url.Values{} + q.Set("query", query) + q.Set("time", fmt.Sprintf("%d", end.UnixNano())) + body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/query", q) + if err != nil { + return 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 fmt.Errorf("解析 Loki 响应失败: %w", err) + } + + type row struct { + ns, app string + entries int64 + } + rows := make([]row, 0, len(resp.Data.Result)) + for _, item := range resp.Data.Result { + app := item.Metric["app"] + if app == "" { + app = item.Metric["service_name"] + } + if app == "" { + app = item.Metric["container"] + } + if app == "" { + continue + } + value, _ := strconv.ParseFloat(fmt.Sprintf("%v", item.Value[1]), 64) + if value <= 0 { + continue + } + rows = append(rows, row{ns: item.Metric["namespace"], app: app, entries: int64(value)}) + } + // 只留 top N,控制快照体积 + sort.Slice(rows, func(i, j int) bool { return rows[i].entries > rows[j].entries }) + if len(rows) > appVolumeSampleTopN { + rows = rows[:appVolumeSampleTopN] + } + if len(rows) == 0 { + return nil + } + + db := repository.GetDB() + if db == nil { + return fmt.Errorf("数据库未初始化") + } + db.Lock() + defer db.Unlock() + nextID := db.NextID("appVolumeSnapshots") + for _, r := range rows { + // 幂等:同 (hour, source, ns, app) 已存在则更新,不重复插入 + found := false + for i := range db.AppVolumeSnapshots { + 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.CapturedAt = time.Now() + found = true + break + } + } + if found { + continue + } + 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(), + }) + nextID++ + } + pruneAppVolumeLocked(db) + db.RequestSave() + log.Printf("[app-volume] %s 已留存 %d 个应用的量", key, len(rows)) + return nil +} + +// pruneAppVolumeLocked 按保留期裁剪(调用方必须已持写锁)。 +func pruneAppVolumeLocked(db *repository.JsonDB) { + cutoff := hourKey(time.Now().AddDate(0, 0, -appVolumeRetentionDays)) + keep := db.AppVolumeSnapshots[:0] + for _, snap := range db.AppVolumeSnapshots { + if snap.Hour >= cutoff { + keep = append(keep, snap) + } + } + db.AppVolumeSnapshots = keep +} + +// CaptureMissingAppVolume 回补最近 N 个**完整小时**里还没抓过的那些。 +func CaptureMissingAppVolume(hours int) { + if hours <= 0 { + hours = 2 + } + db := repository.GetDB() + if db == nil { + return + } + db.RLock() + sources := make([]models.LogSource, 0, len(db.LogSources)) + for _, s := range db.LogSources { + sources = append(sources, s) + } + captured := map[string]bool{} + for _, snap := range db.AppVolumeSnapshots { + captured[fmt.Sprintf("%d|%s", snap.SourceID, snap.Hour)] = true + } + db.RUnlock() + + now := time.Now() + for _, src := range sources { + for i := 1; i <= hours; i++ { + hour := now.Add(-time.Duration(i) * time.Hour).Truncate(time.Hour) + if captured[fmt.Sprintf("%d|%s", src.ID, hourKey(hour))] { + continue + } + // 超出 Loki 保留期的窗口不抓(抓也抓不到,白等几分钟) + if time.Since(hour.Add(time.Hour)) > 26*time.Hour { + continue + } + if err := CaptureAppVolumeForHour(&src, hour); err != nil { + log.Printf("[app-volume] %s %s 抓取失败: %v", src.Name, hourKey(hour), err) + } + } + } +} + +// StartAppVolumeCapture 后台任务:每小时第 5 分钟抓上一个完整小时。 +func StartAppVolumeCapture() { + go func() { + time.Sleep(45 * time.Second) // 避开启动时的其它任务 + CaptureMissingAppVolume(2) + for { + now := time.Now() + // 下一个整点后 5 分钟 + next := now.Truncate(time.Hour).Add(time.Hour + 5*time.Minute) + time.Sleep(time.Until(next)) + CaptureMissingAppVolume(3) + } + }() +} + +// GetAppRanking 从**预聚合快照**里取指定时间范围的应用排行(毫秒级)。 +func GetAppRanking(sourceID uint, from, to time.Time, limit int) []AppRankRow { + if limit <= 0 { + limit = 10 + } + fromHour := hourKey(from) + toHour := hourKey(to) + + db := repository.GetDB() + if db == nil { + return nil + } + db.RLock() + defer db.RUnlock() + + type agg struct { + ns, app string + entries int64 + } + sum := map[string]*agg{} + for _, snap := range db.AppVolumeSnapshots { + if snap.SourceID != sourceID || snap.Hour < fromHour || snap.Hour > toHour { + continue + } + key := snap.Namespace + "\x00" + snap.App + item, ok := sum[key] + if !ok { + item = &agg{ns: snap.Namespace, app: snap.App} + sum[key] = item + } + item.entries += snap.Entries + } + 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}) + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].Entries == rows[j].Entries { + return rows[i].App < rows[j].App + } + return rows[i].Entries > rows[j].Entries + }) + if len(rows) > limit { + rows = rows[:limit] + } + return rows +} + +// AppVolumeCoverage 返回快照覆盖的小时数,前端用来提示"历史只有 N 小时数据"。 +func AppVolumeCoverage(sourceID uint) int { + db := repository.GetDB() + if db == nil { + return 0 + } + db.RLock() + defer db.RUnlock() + hours := map[string]bool{} + for _, snap := range db.AppVolumeSnapshots { + if snap.SourceID == sourceID { + hours[snap.Hour] = true + } + } + return len(hours) +} diff --git a/frontend/src/api/logs/index.ts b/frontend/src/api/logs/index.ts index 8177f6b..de35bcc 100644 --- a/frontend/src/api/logs/index.ts +++ b/frontend/src/api/logs/index.ts @@ -45,6 +45,13 @@ export const logsApi = { http.post<{ streams: number; chunks: number; bytes: number; entries: number }>( '/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, + ), + /** 每日日志量历史(读平台库,毫秒级) */ dailyVolume: (sourceId: number, days: number, opts?: { signal?: AbortSignal }) => http.get(`/logs/daily-volume?sourceId=${sourceId}&days=${days}`, opts), diff --git a/frontend/src/hooks/useInView.ts b/frontend/src/hooks/useInView.ts index adccf14..ad949e0 100644 --- a/frontend/src/hooks/useInView.ts +++ b/frontend/src/hooks/useInView.ts @@ -1,64 +1,80 @@ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' /** * useInView 判断元素是否进入视口(默认提前 200px 触发)。 * - * 用途:分析页里"按应用统计"这类必须读日志内容的查询很贵(实测大集群 - * 15 分钟约 20~30 秒)。一次性全查会让首屏一起等,用户看到的是整页转圈。 - * 改成滚动到该卡片附近才发查询:上面的总量/趋势先出来,下面的慢数据后补。 + * 用途:分析页里"按应用统计"这类必须读日志内容的查询很贵(实测 15 分钟 + * 十几秒)。改成滚动到该卡片附近才发查询:上面的总量/趋势先出来,慢数据后补。 + * + * ⚠️ 历史 bug(务必保留这个写法): + * 最初用 `useRef` + `useEffect([inView])`。分析页首屏 `data === null` 时 + * 整个内容块不渲染,`ref.current` 是 null,effect 直接 return; + * 等数据到了 DOM 才挂上,但依赖没变 → effect 不会重跑 → + * **IntersectionObserver 从未创建,inView 永远 false,重查询永不发出**, + * 页面就一直显示"没有应用维度的数据"。 + * + * 现在改用 **callback ref**(节点挂载/卸载都会触发,作为 effect 依赖), + * 无论元素是首屏就有、还是数据回来后才有,都能正确观察到。 * * 三重保障(懒加载只管"早点晚点",绝不能变成"永远不加载"): - * 1) IntersectionObserver 正常路径,进入视口即触发 - * 2) 1 秒后兜底量一次 getBoundingClientRect —— 有些容器(transform / - * overflow / 自定义滚动区)里 IO 的 root 判定会和预期不一致,实测出现过 - * "卡片明明在屏幕上、观察器却一直不回调",于是数据永远不出来 - * 3) 组件卸载时清理定时器与观察器 - * - * 只触发一次(日志分析是只读快照,不需要来回重算)。 + * 1) IntersectionObserver 正常路径 + * 2) scroll / resize 时用 getBoundingClientRect 兜底(IO 在自定义滚动容器 + * 或 transform 场景下偶发不回调,实测踩过) + * 3) 挂载瞬间先量一次(元素本来就在视口内时立刻触发) */ export function useInView(rootMargin = '200px') { - const ref = useRef(null) + const [node, setNode] = useState(null) const [inView, setInView] = useState(false) + // callback ref:元素从"未挂载"变成"已挂载"时也会更新,作为 effect 依赖 + const ref = useCallback((el: T | null) => setNode(el), []) + useEffect(() => { - if (inView) return - const el = ref.current - if (!el) return + if (!node || inView) return - // 兜底判定:元素顶边在视口内(含 rootMargin 余量)就算可见 const margin = Number.parseInt(rootMargin, 10) || 200 - const isVisiblyInViewport = () => { - const rect = el.getBoundingClientRect() + const visible = () => { + const rect = node.getBoundingClientRect() const viewportHeight = window.innerHeight || document.documentElement.clientHeight return rect.top < viewportHeight + margin && rect.bottom > -margin } - // 路径 1:IntersectionObserver + // 路径 1:已经可见就直接触发(数据回来后元素本来就在屏幕上) + if (visible()) { + setInView(true) + return + } + let observer: IntersectionObserver | undefined if (typeof IntersectionObserver !== 'undefined') { observer = new IntersectionObserver( entries => { - if (entries.some(entry => entry.isIntersecting)) { - setInView(true) - observer?.disconnect() - } + if (entries.some(entry => entry.isIntersecting)) setInView(true) }, { rootMargin }, ) - observer.observe(el) + observer.observe(node) } - // 路径 2:几何兜底(覆盖 IO 不回调的情况) - const fallback = window.setTimeout(() => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (isVisiblyInViewport()) setInView(true) - }, 1000) + // 路径 2:滚动/resize 兜底(rAF 节流) + let ticking = false + const onScroll = () => { + if (ticking) return + ticking = true + window.requestAnimationFrame(() => { + ticking = false + if (visible()) setInView(true) + }) + } + window.addEventListener('scroll', onScroll, { passive: true }) + window.addEventListener('resize', onScroll) return () => { observer?.disconnect() - window.clearTimeout(fallback) + window.removeEventListener('scroll', onScroll) + window.removeEventListener('resize', onScroll) } - }, [inView, rootMargin]) + }, [node, inView, rootMargin]) return { ref, inView } } diff --git a/frontend/src/i18n/locales/en/analysis.ts b/frontend/src/i18n/locales/en/analysis.ts index dc433ff..027bc84 100644 --- a/frontend/src/i18n/locales/en/analysis.ts +++ b/frontend/src/i18n/locales/en/analysis.ts @@ -65,6 +65,7 @@ export default { }, table: { + 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.', 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', diff --git a/frontend/src/i18n/locales/zh/analysis.ts b/frontend/src/i18n/locales/zh/analysis.ts index 7a364a8..902947d 100644 --- a/frontend/src/i18n/locales/zh/analysis.ts +++ b/frontend/src/i18n/locales/zh/analysis.ts @@ -65,6 +65,7 @@ export default { }, table: { + subtitleHistory: '{{range}} 分钟范围超过了实时扫描上限,这里读平台按小时预聚合的快照(已留存 {{hours}} 小时)——毫秒级返回,但不含错误数与趋势。', title: '应用日志量排行', subtitle: '按日志条数排前 10 的应用,点击「查看日志」跳转至查询页', computing: '正在按应用统计(需读取日志内容,通常 20~30 秒)…', diff --git a/frontend/src/pages/analysis/index.tsx b/frontend/src/pages/analysis/index.tsx index d3156cf..67e4822 100644 --- a/frontend/src/pages/analysis/index.tsx +++ b/frontend/src/pages/analysis/index.tsx @@ -14,7 +14,7 @@ import { StatCard } from '@/components/StatCard' import { TimeRangePicker } from '@/components/TimeRangePicker' import { logsApi, type LogSource } from '@/api/logs' import { getUser } from '@/lib/auth-storage' -import { useAnalysisMetrics } from './hooks/useAnalysisMetrics' +import { useAnalysisMetrics, type AppRow } from './hooks/useAnalysisMetrics' import { useDailyVolume } from './hooks/useDailyVolume' import { useInView } from '@/hooks/useInView' import { compactNumber, formatBytes } from './components/chart-shared' @@ -39,17 +39,17 @@ const AnalysisPage = memo(function AnalysisPage() { const [sources, setSources] = useState([]) const [sourceId, setSourceId] = useState(0) - // 默认 15 分钟:分析页一次性要跑 5 条 count_over_time, - // 1 小时窗口在日志量大的命名空间要十几秒;15 分钟既能看出趋势又能快速出结果。 - // ── "选择值" 与 "已生效值" 分离 ────────────────────────────────── + // 默认 5 分钟:按应用统计要读日志内容(Loki 只能扫 chunk,不能走索引), + // 15 分钟在 370GB/天的量下要 12~15 秒,用户等不住。 + // 5 分钟扫描量降到 1/3,实测 4~5 秒就能出排行;需要看更长趋势时手动改时间范围。 + const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 5 * 60 * 1000))) // start/end/namespace 是**已生效**值,查询只认它们; // sel* 是筛选栏里的**选择**值。 // 这样切换命名空间/时间只改变选择,不触发查询;点「刷新」才生效。 // 原因:一次分析要跑多条 count_over_time,切一下下拉就重查一轮很浪费, // 用户快速连点几下会把 Loki 打满(并发护栏 429)。 - const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 15 * 60 * 1000))) const [end, setEnd] = useState(() => toLocal(new Date())) - const [selStart, setSelStart] = useState(() => toLocal(new Date(Date.now() - 15 * 60 * 1000))) + const [selStart, setSelStart] = useState(() => toLocal(new Date(Date.now() - 5 * 60 * 1000))) const [selEnd, setSelEnd] = useState(() => toLocal(new Date())) /** @@ -137,11 +137,60 @@ const AnalysisPage = memo(function AnalysisPage() { // 只在滚动到视口附近时才发查询(每天量、应用排行、Ingress 分析)。 const dailyView = useInView() const rankingView = useInView() + // 最后一道保险:可见性判定万一失灵(自定义滚动容器 / 元素被包在 + // transform 里等场景都踩过),8 秒后无条件查一次。 + // 懒加载的目的是"别让首屏等",不该变成"永远不加载"。 + const [forceRanking, setForceRanking] = useState(false) + useEffect(() => { + setForceRanking(false) + const timer = window.setTimeout(() => setForceRanking(true), 8000) + return () => window.clearTimeout(timer) + }, [effectiveSourceId]) const ingressView = useInView() // ⚠️ 不门控这条查询:顶部的「活跃应用」「应用日志量分布」「应用日志量流图」 // 都依赖 by-app 数据,而它原来被底部排行卡片的可见性门控 —— // 用户不滚到底就永远拿不到数据,表现为"三条都没有应用维度的数据"。 // 现在进页面就发,页面用两阶段渲染:索引类指标先出,app 维度到货后补。 + // ── 长范围排行走平台预聚合 ─────────────────────────────────────── + // 按应用排行必须读日志内容,成本 ∝ 时间范围(5 分钟 10s、15 分钟 20s、 + // 6 小时 5~8 分钟、24 小时不可用)。所以 >1 小时的范围改读 + // 后台每小时预聚合的快照(毫秒级),前台不再扫日志内容。 + const rangeMinutes = Math.max( + 1, + Math.round((new Date(end).getTime() - new Date(start).getTime()) / 60000), + ) + const useHistoryRanking = rangeMinutes > 60 + const [historyApps, setHistoryApps] = useState(null) + const [historyHours, setHistoryHours] = useState(0) + + useEffect(() => { + if (!useHistoryRanking || !effectiveSourceId) { + setHistoryApps(null) + return + } + const controller = new AbortController() + setHistoryApps(null) + logsApi + .appRanking(effectiveSourceId, new Date(start).getTime() * 1e6, new Date(end).getTime() * 1e6, 10, { + signal: controller.signal, + }) + .then(res => { + const rows: AppRow[] = (res?.list ?? []).map(item => ({ + app: item.app, + namespace: item.namespace, + namespaces: item.namespace ? [item.namespace] : [], + total: item.entries, + // 预聚合只统计条数,没有错误数/趋势 —— 表格对应列会显示 — + errorCount: 0, + sparkline: [], + })) + setHistoryApps(rows) + setHistoryHours(res?.hours ?? 0) + }) + .catch(() => setHistoryApps([])) + return () => controller.abort() + }, [useHistoryRanking, effectiveSourceId, start, end]) + const { data, loading, appsLoading, error, appLabel, refresh } = useAnalysisMetrics({ sourceId: effectiveSourceId, start, @@ -151,7 +200,7 @@ const AnalysisPage = memo(function AnalysisPage() { // 因此重新用可见性门控:页面打开只跑索引类查询(百毫秒), // 用户滚到排行卡才发那条 10~15 秒的重查询。 // 这是真正减少查询压力的一步——之前图在首屏,所以只能全量查。 - rankingEnabled: rankingView.inView, + rankingEnabled: !useHistoryRanking && (rankingView.inView || forceRanking), }) // 选择值与已生效值不一致 → 提示"点刷新生效" @@ -291,11 +340,15 @@ const AnalysisPage = memo(function AnalysisPage() { {t('table.title')} -

{t('table.subtitle')}

+

+ {useHistoryRanking + ? t('table.subtitleHistory', { hours: historyHours, range: rangeMinutes }) + : t('table.subtitle')} +

{/* 副标题说明这块不需要读日志内容,避免和上面的排行混淆 */}
- {(appsLoading || loading) && !data.apps.length ? ( + {(useHistoryRanking ? historyApps === null : (appsLoading || loading)) && !(useHistoryRanking ? (historyApps ?? []) : data.apps).length ? ( // 这块必须按 app 分组统计(读日志内容),实测大集群 15 分钟约 20~30 秒。 // 明确告诉用户在算什么、要等多久,比空表格或整页转圈好。