refactor(analysis): 去掉排行 10/30/50 选择器,改为一次取 50 + 前端分页;零点结算昨天
CI / Backend (go vet + test) (push) Canceled after 0s
CI / Frontend (lint + build + test) (push) Canceled after 0s

=== 1. 为什么去掉「显示条数」选择器 ===
topk 的 N 不影响查询成本(扫描的 chunk 集合完全相同),实测:
  topk(10)  17833ms / 11 条
  topk(30)  18019ms / 34 条
  topk(50)   7394ms / 54 条
  topk(100) 17553ms / 74 条
波动来自集群负载,与 N 无关 —— 它只决定最后返回多少序列。
所以"选 30"既不更快也不更慢,本质是个翻页开关;
而选它还要重新等一次十几秒的查询,纯亏。
改为:一次 topk(50),前端 10 条/页 → 5 页只需 1 次查询。
附带收益:占比的分母从"前 10 合计"变成"前 50 合计",更接近真实占比。
副标题明确写清"一次取前 50 个应用(占比按这 50 个合计计算)"。

=== 2. 零点结算昨天(daily volume)===
原来只有 30 分钟 ticker 回补,且启动时间不对齐整点 →
昨天最早的一段可能已经超出 Loki 24h 保留期被删,
结算结果系统性偏低,图上却标"完整一天",属静默错数。
新增 nextMidnightFinalize():每天 00:01 触发 FinalizeYesterdayVolume()
  - 昨天定稿 partial=false
  - 补一次条数(index/stats,一天一次可接受)
  - 顺带刷新今天 partial=true 的柱子
ticker 仍保留,负责当天数据的 30 分钟刷新。

=== 3. 顺带 ===
- 去掉每日量"其中 N 天平台没有留存记录"的提示文案(按用户要求)
- 修 QuickQueryTemplates.tsx 的未使用变量导致 tsc -b 失败
- 工作区里上一轮中断的"排行体积列"改动一并纳入(app-ranking 返回 bytes,
  TopAppsTable 增加体积列,快照记录 per-app 字节数)

Pod = Hub latest = a16978d2f88b
This commit is contained in:
opsx committed 2026-10-30 17:13:18 +00:00
1 parent 63dd491c5b
commit d4422f1541
22 files changed
+715 -117

No files matched your search

+97 -12
View File
@@ -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)
}