feat(analysis): 长范围应用排行改走「小时级预聚合」,不再扫日志内容
=== 问题(实测)===
按应用排行必须读日志内容(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
This commit is contained in:
1 parent
7f6fb364de
commit
d0adc095dd
11 files changed
+467
-40
No files matched your search
@@ -330,6 +330,29 @@ func (ctrl *LogsController) Health(c *gin.Context) {
|
|||||||
utils.Success(c, gin.H{"ready": services.LokiReady(ctx, src)})
|
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 限制)。
|
// DailyVolume 每日日志量历史(平台留存,不受 Loki retention 限制)。
|
||||||
//
|
//
|
||||||
// query: sourceId, days(默认 7,最长 400)
|
// query: sourceId, days(默认 7,最长 400)
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ func main() {
|
|||||||
services.CleanupOrphanManagedLogSources()
|
services.CleanupOrphanManagedLogSources()
|
||||||
// 异步重新探测运行中部署的数据源连接方式(平台/Loki 可能在主机或集群内)
|
// 异步重新探测运行中部署的数据源连接方式(平台/Loki 可能在主机或集群内)
|
||||||
go services.EnsureLogSourcesForRunningDeployments()
|
go services.EnsureLogSourcesForRunningDeployments()
|
||||||
|
// 按应用日志量的**小时级预聚合**:每小时抓上一个完整小时,
|
||||||
|
// 让 6h/24h 这类长范围的"应用排行"变成读库(毫秒级),不扫日志内容。
|
||||||
|
go services.StartAppVolumeCapture()
|
||||||
// 补齐/升级内置告警规则(幂等):启动即执行,存量环境也能拿到「默认启用+绑默认通道」
|
// 补齐/升级内置告警规则(幂等):启动即执行,存量环境也能拿到「默认启用+绑默认通道」
|
||||||
seed.SeedAlertRules()
|
seed.SeedAlertRules()
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ type JsonDB struct {
|
|||||||
AuditLogs []models.AuditLog `json:"auditLogs"`
|
AuditLogs []models.AuditLog `json:"auditLogs"`
|
||||||
// DailyVolumeSnapshots 每日日志量历史(Loki retention 只有 24h,历史必须自己留)
|
// DailyVolumeSnapshots 每日日志量历史(Loki retention 只有 24h,历史必须自己留)
|
||||||
DailyVolumeSnapshots []models.DailyVolumeSnapshot `json:"dailyVolumeSnapshots"`
|
DailyVolumeSnapshots []models.DailyVolumeSnapshot `json:"dailyVolumeSnapshots"`
|
||||||
|
// AppVolumeSnapshots 按小时留存的 per-app 日志量(长范围排行读这个,不扫日志内容)
|
||||||
|
AppVolumeSnapshots []models.AppVolumeSnapshot `json:"appVolumeSnapshots"`
|
||||||
|
|
||||||
// SchemaVersion 数据结构版本号。
|
// SchemaVersion 数据结构版本号。
|
||||||
// 之前加字段全靠 JSON 反序列化的零值兜底,字段一多必然出事
|
// 之前加字段全靠 JSON 反序列化的零值兜底,字段一多必然出事
|
||||||
@@ -612,6 +614,7 @@ func (d *JsonDB) Reload() error {
|
|||||||
d.LogPolicyGroups = fresh.LogPolicyGroups
|
d.LogPolicyGroups = fresh.LogPolicyGroups
|
||||||
d.AuditLogs = fresh.AuditLogs
|
d.AuditLogs = fresh.AuditLogs
|
||||||
d.DailyVolumeSnapshots = fresh.DailyVolumeSnapshots
|
d.DailyVolumeSnapshots = fresh.DailyVolumeSnapshots
|
||||||
|
d.AppVolumeSnapshots = fresh.AppVolumeSnapshots
|
||||||
d.SchemaVersion = fresh.SchemaVersion
|
d.SchemaVersion = fresh.SchemaVersion
|
||||||
d.sequences = map[string]uint{}
|
d.sequences = map[string]uint{}
|
||||||
for _, u := range d.Users {
|
for _, u := range d.Users {
|
||||||
@@ -684,6 +687,11 @@ func (d *JsonDB) Reload() error {
|
|||||||
d.sequences["dailyVolumeSnapshots"] = v.ID
|
d.sequences["dailyVolumeSnapshots"] = v.ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, v := range d.AppVolumeSnapshots {
|
||||||
|
if v.ID > d.sequences["appVolumeSnapshots"] {
|
||||||
|
d.sequences["appVolumeSnapshots"] = v.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -851,6 +859,7 @@ func InitDB() error {
|
|||||||
"alertDeliveries": 0,
|
"alertDeliveries": 0,
|
||||||
"auditLogs": 0,
|
"auditLogs": 0,
|
||||||
"dailyVolumeSnapshots": 0,
|
"dailyVolumeSnapshots": 0,
|
||||||
|
"appVolumeSnapshots": 0,
|
||||||
},
|
},
|
||||||
saveChan: make(chan saveRequest, 10),
|
saveChan: make(chan saveRequest, 10),
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ func SetupRouter() *gin.Engine {
|
|||||||
logs.POST("/histogram", logsCtrl.Histogram)
|
logs.POST("/histogram", logsCtrl.Histogram)
|
||||||
logs.GET("/histogram", logsCtrl.Histogram)
|
logs.GET("/histogram", logsCtrl.Histogram)
|
||||||
logs.GET("/daily-volume", logsCtrl.DailyVolume)
|
logs.GET("/daily-volume", logsCtrl.DailyVolume)
|
||||||
|
// 应用排行:长范围读平台预聚合快照(毫秒级),不扫日志内容
|
||||||
|
logs.GET("/app-ranking", logsCtrl.AppRanking)
|
||||||
// 总量统计(index/stats):只读索引,用于算总页数
|
// 总量统计(index/stats):只读索引,用于算总页数
|
||||||
logs.POST("/stats", logsCtrl.Stats)
|
logs.POST("/stats", logsCtrl.Stats)
|
||||||
logs.POST("/metrics", middleware.AdminAuth(), logsCtrl.Metrics)
|
logs.POST("/metrics", middleware.AdminAuth(), logsCtrl.Metrics)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -45,6 +45,13 @@ export const logsApi = {
|
|||||||
http.post<{ streams: number; chunks: number; bytes: number; entries: number }>(
|
http.post<{ streams: number; chunks: number; bytes: number; entries: number }>(
|
||||||
'/logs/stats', data, { timeout: 25000, ...opts }),
|
'/logs/stats', data, { timeout: 25000, ...opts }),
|
||||||
/** 日志量统计:后端有 7 天范围上限与 30s 超时,前端超时略大于它 */
|
/** 日志量统计:后端有 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 }) =>
|
dailyVolume: (sourceId: number, days: number, opts?: { signal?: AbortSignal }) =>
|
||||||
http.get<DailyVolumePoint[]>(`/logs/daily-volume?sourceId=${sourceId}&days=${days}`, opts),
|
http.get<DailyVolumePoint[]>(`/logs/daily-volume?sourceId=${sourceId}&days=${days}`, opts),
|
||||||
|
|||||||
@@ -1,64 +1,80 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useInView 判断元素是否进入视口(默认提前 200px 触发)。
|
* useInView 判断元素是否进入视口(默认提前 200px 触发)。
|
||||||
*
|
*
|
||||||
* 用途:分析页里"按应用统计"这类必须读日志内容的查询很贵(实测大集群
|
* 用途:分析页里"按应用统计"这类必须读日志内容的查询很贵(实测 15 分钟
|
||||||
* 15 分钟约 20~30 秒)。一次性全查会让首屏一起等,用户看到的是整页转圈。
|
* 十几秒)。改成滚动到该卡片附近才发查询:上面的总量/趋势先出来,慢数据后补。
|
||||||
* 改成滚动到该卡片附近才发查询:上面的总量/趋势先出来,下面的慢数据后补。
|
*
|
||||||
|
* ⚠️ 历史 bug(务必保留这个写法):
|
||||||
|
* 最初用 `useRef` + `useEffect([inView])`。分析页首屏 `data === null` 时
|
||||||
|
* 整个内容块不渲染,`ref.current` 是 null,effect 直接 return;
|
||||||
|
* 等数据到了 DOM 才挂上,但依赖没变 → effect 不会重跑 →
|
||||||
|
* **IntersectionObserver 从未创建,inView 永远 false,重查询永不发出**,
|
||||||
|
* 页面就一直显示"没有应用维度的数据"。
|
||||||
|
*
|
||||||
|
* 现在改用 **callback ref**(节点挂载/卸载都会触发,作为 effect 依赖),
|
||||||
|
* 无论元素是首屏就有、还是数据回来后才有,都能正确观察到。
|
||||||
*
|
*
|
||||||
* 三重保障(懒加载只管"早点晚点",绝不能变成"永远不加载"):
|
* 三重保障(懒加载只管"早点晚点",绝不能变成"永远不加载"):
|
||||||
* 1) IntersectionObserver 正常路径,进入视口即触发
|
* 1) IntersectionObserver 正常路径
|
||||||
* 2) 1 秒后兜底量一次 getBoundingClientRect —— 有些容器(transform /
|
* 2) scroll / resize 时用 getBoundingClientRect 兜底(IO 在自定义滚动容器
|
||||||
* overflow / 自定义滚动区)里 IO 的 root 判定会和预期不一致,实测出现过
|
* 或 transform 场景下偶发不回调,实测踩过)
|
||||||
* "卡片明明在屏幕上、观察器却一直不回调",于是数据永远不出来
|
* 3) 挂载瞬间先量一次(元素本来就在视口内时立刻触发)
|
||||||
* 3) 组件卸载时清理定时器与观察器
|
|
||||||
*
|
|
||||||
* 只触发一次(日志分析是只读快照,不需要来回重算)。
|
|
||||||
*/
|
*/
|
||||||
export function useInView<T extends HTMLElement>(rootMargin = '200px') {
|
export function useInView<T extends HTMLElement>(rootMargin = '200px') {
|
||||||
const ref = useRef<T | null>(null)
|
const [node, setNode] = useState<T | null>(null)
|
||||||
const [inView, setInView] = useState(false)
|
const [inView, setInView] = useState(false)
|
||||||
|
|
||||||
|
// callback ref:元素从"未挂载"变成"已挂载"时也会更新,作为 effect 依赖
|
||||||
|
const ref = useCallback((el: T | null) => setNode(el), [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inView) return
|
if (!node || inView) return
|
||||||
const el = ref.current
|
|
||||||
if (!el) return
|
|
||||||
|
|
||||||
// 兜底判定:元素顶边在视口内(含 rootMargin 余量)就算可见
|
|
||||||
const margin = Number.parseInt(rootMargin, 10) || 200
|
const margin = Number.parseInt(rootMargin, 10) || 200
|
||||||
const isVisiblyInViewport = () => {
|
const visible = () => {
|
||||||
const rect = el.getBoundingClientRect()
|
const rect = node.getBoundingClientRect()
|
||||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
||||||
return rect.top < viewportHeight + margin && rect.bottom > -margin
|
return rect.top < viewportHeight + margin && rect.bottom > -margin
|
||||||
}
|
}
|
||||||
|
|
||||||
// 路径 1:IntersectionObserver
|
// 路径 1:已经可见就直接触发(数据回来后元素本来就在屏幕上)
|
||||||
|
if (visible()) {
|
||||||
|
setInView(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let observer: IntersectionObserver | undefined
|
let observer: IntersectionObserver | undefined
|
||||||
if (typeof IntersectionObserver !== 'undefined') {
|
if (typeof IntersectionObserver !== 'undefined') {
|
||||||
observer = new IntersectionObserver(
|
observer = new IntersectionObserver(
|
||||||
entries => {
|
entries => {
|
||||||
if (entries.some(entry => entry.isIntersecting)) {
|
if (entries.some(entry => entry.isIntersecting)) setInView(true)
|
||||||
setInView(true)
|
|
||||||
observer?.disconnect()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{ rootMargin },
|
{ rootMargin },
|
||||||
)
|
)
|
||||||
observer.observe(el)
|
observer.observe(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 路径 2:几何兜底(覆盖 IO 不回调的情况)
|
// 路径 2:滚动/resize 兜底(rAF 节流)
|
||||||
const fallback = window.setTimeout(() => {
|
let ticking = false
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
const onScroll = () => {
|
||||||
if (isVisiblyInViewport()) setInView(true)
|
if (ticking) return
|
||||||
}, 1000)
|
ticking = true
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
ticking = false
|
||||||
|
if (visible()) setInView(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
window.addEventListener('resize', onScroll)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
observer?.disconnect()
|
observer?.disconnect()
|
||||||
window.clearTimeout(fallback)
|
window.removeEventListener('scroll', onScroll)
|
||||||
|
window.removeEventListener('resize', onScroll)
|
||||||
}
|
}
|
||||||
}, [inView, rootMargin])
|
}, [node, inView, rootMargin])
|
||||||
|
|
||||||
return { ref, inView }
|
return { ref, inView }
|
||||||
}
|
}
|
||||||
@@ -65,6 +65,7 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
table: {
|
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',
|
title: 'App volume ranking',
|
||||||
computing: 'Computing per-app volume (reads log content, usually 20–30s)…',
|
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: 'Top 10 apps by volume; click "View logs" to open the query page',
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
table: {
|
table: {
|
||||||
|
subtitleHistory: '{{range}} 分钟范围超过了实时扫描上限,这里读平台按小时预聚合的快照(已留存 {{hours}} 小时)——毫秒级返回,但不含错误数与趋势。',
|
||||||
title: '应用日志量排行',
|
title: '应用日志量排行',
|
||||||
subtitle: '按日志条数排前 10 的应用,点击「查看日志」跳转至查询页',
|
subtitle: '按日志条数排前 10 的应用,点击「查看日志」跳转至查询页',
|
||||||
computing: '正在按应用统计(需读取日志内容,通常 20~30 秒)…',
|
computing: '正在按应用统计(需读取日志内容,通常 20~30 秒)…',
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { StatCard } from '@/components/StatCard'
|
|||||||
import { TimeRangePicker } from '@/components/TimeRangePicker'
|
import { TimeRangePicker } from '@/components/TimeRangePicker'
|
||||||
import { logsApi, type LogSource } from '@/api/logs'
|
import { logsApi, type LogSource } from '@/api/logs'
|
||||||
import { getUser } from '@/lib/auth-storage'
|
import { getUser } from '@/lib/auth-storage'
|
||||||
import { useAnalysisMetrics } from './hooks/useAnalysisMetrics'
|
import { useAnalysisMetrics, type AppRow } from './hooks/useAnalysisMetrics'
|
||||||
import { useDailyVolume } from './hooks/useDailyVolume'
|
import { useDailyVolume } from './hooks/useDailyVolume'
|
||||||
import { useInView } from '@/hooks/useInView'
|
import { useInView } from '@/hooks/useInView'
|
||||||
import { compactNumber, formatBytes } from './components/chart-shared'
|
import { compactNumber, formatBytes } from './components/chart-shared'
|
||||||
@@ -39,17 +39,17 @@ const AnalysisPage = memo(function AnalysisPage() {
|
|||||||
|
|
||||||
const [sources, setSources] = useState<LogSource[]>([])
|
const [sources, setSources] = useState<LogSource[]>([])
|
||||||
const [sourceId, setSourceId] = useState(0)
|
const [sourceId, setSourceId] = useState(0)
|
||||||
// 默认 15 分钟:分析页一次性要跑 5 条 count_over_time,
|
// 默认 5 分钟:按应用统计要读日志内容(Loki 只能扫 chunk,不能走索引),
|
||||||
// 1 小时窗口在日志量大的命名空间要十几秒;15 分钟既能看出趋势又能快速出结果。
|
// 15 分钟在 370GB/天的量下要 12~15 秒,用户等不住。
|
||||||
// ── "选择值" 与 "已生效值" 分离 ──────────────────────────────────
|
// 5 分钟扫描量降到 1/3,实测 4~5 秒就能出排行;需要看更长趋势时手动改时间范围。
|
||||||
|
const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 5 * 60 * 1000)))
|
||||||
// start/end/namespace 是**已生效**值,查询只认它们;
|
// start/end/namespace 是**已生效**值,查询只认它们;
|
||||||
// sel* 是筛选栏里的**选择**值。
|
// sel* 是筛选栏里的**选择**值。
|
||||||
// 这样切换命名空间/时间只改变选择,不触发查询;点「刷新」才生效。
|
// 这样切换命名空间/时间只改变选择,不触发查询;点「刷新」才生效。
|
||||||
// 原因:一次分析要跑多条 count_over_time,切一下下拉就重查一轮很浪费,
|
// 原因:一次分析要跑多条 count_over_time,切一下下拉就重查一轮很浪费,
|
||||||
// 用户快速连点几下会把 Loki 打满(并发护栏 429)。
|
// 用户快速连点几下会把 Loki 打满(并发护栏 429)。
|
||||||
const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 15 * 60 * 1000)))
|
|
||||||
const [end, setEnd] = useState(() => toLocal(new Date()))
|
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()))
|
const [selEnd, setSelEnd] = useState(() => toLocal(new Date()))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,11 +137,60 @@ const AnalysisPage = memo(function AnalysisPage() {
|
|||||||
// 只在滚动到视口附近时才发查询(每天量、应用排行、Ingress 分析)。
|
// 只在滚动到视口附近时才发查询(每天量、应用排行、Ingress 分析)。
|
||||||
const dailyView = useInView<HTMLDivElement>()
|
const dailyView = useInView<HTMLDivElement>()
|
||||||
const rankingView = useInView<HTMLDivElement>()
|
const rankingView = useInView<HTMLDivElement>()
|
||||||
|
// 最后一道保险:可见性判定万一失灵(自定义滚动容器 / 元素被包在
|
||||||
|
// 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<HTMLDivElement>()
|
const ingressView = useInView<HTMLDivElement>()
|
||||||
// ⚠️ 不门控这条查询:顶部的「活跃应用」「应用日志量分布」「应用日志量流图」
|
// ⚠️ 不门控这条查询:顶部的「活跃应用」「应用日志量分布」「应用日志量流图」
|
||||||
// 都依赖 by-app 数据,而它原来被底部排行卡片的可见性门控 ——
|
// 都依赖 by-app 数据,而它原来被底部排行卡片的可见性门控 ——
|
||||||
// 用户不滚到底就永远拿不到数据,表现为"三条都没有应用维度的数据"。
|
// 用户不滚到底就永远拿不到数据,表现为"三条都没有应用维度的数据"。
|
||||||
// 现在进页面就发,页面用两阶段渲染:索引类指标先出,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<AppRow[] | null>(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({
|
const { data, loading, appsLoading, error, appLabel, refresh } = useAnalysisMetrics({
|
||||||
sourceId: effectiveSourceId,
|
sourceId: effectiveSourceId,
|
||||||
start,
|
start,
|
||||||
@@ -151,7 +200,7 @@ const AnalysisPage = memo(function AnalysisPage() {
|
|||||||
// 因此重新用可见性门控:页面打开只跑索引类查询(百毫秒),
|
// 因此重新用可见性门控:页面打开只跑索引类查询(百毫秒),
|
||||||
// 用户滚到排行卡才发那条 10~15 秒的重查询。
|
// 用户滚到排行卡才发那条 10~15 秒的重查询。
|
||||||
// 这是真正减少查询压力的一步——之前图在首屏,所以只能全量查。
|
// 这是真正减少查询压力的一步——之前图在首屏,所以只能全量查。
|
||||||
rankingEnabled: rankingView.inView,
|
rankingEnabled: !useHistoryRanking && (rankingView.inView || forceRanking),
|
||||||
})
|
})
|
||||||
|
|
||||||
// 选择值与已生效值不一致 → 提示"点刷新生效"
|
// 选择值与已生效值不一致 → 提示"点刷新生效"
|
||||||
@@ -291,11 +340,15 @@ const AnalysisPage = memo(function AnalysisPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-base">{t('table.title')}</CardTitle>
|
<CardTitle className="text-base">{t('table.title')}</CardTitle>
|
||||||
<p className="text-xs text-muted-foreground">{t('table.subtitle')}</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{useHistoryRanking
|
||||||
|
? t('table.subtitleHistory', { hours: historyHours, range: rangeMinutes })
|
||||||
|
: t('table.subtitle')}
|
||||||
|
</p>
|
||||||
{/* 副标题说明这块不需要读日志内容,避免和上面的排行混淆 */}
|
{/* 副标题说明这块不需要读日志内容,避免和上面的排行混淆 */}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{(appsLoading || loading) && !data.apps.length ? (
|
{(useHistoryRanking ? historyApps === null : (appsLoading || loading)) && !(useHistoryRanking ? (historyApps ?? []) : data.apps).length ? (
|
||||||
// 这块必须按 app 分组统计(读日志内容),实测大集群 15 分钟约 20~30 秒。
|
// 这块必须按 app 分组统计(读日志内容),实测大集群 15 分钟约 20~30 秒。
|
||||||
// 明确告诉用户在算什么、要等多久,比空表格或整页转圈好。
|
// 明确告诉用户在算什么、要等多久,比空表格或整页转圈好。
|
||||||
<div className="flex h-40 flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
|
<div className="flex h-40 flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
|||||||
Reference in new issue
Block a user