refactor(analysis): 去掉排行 10/30/50 选择器,改为一次取 50 + 前端分页;零点结算昨天
=== 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:
1 parent
63dd491c5b
commit
d4422f1541
22 files changed
+715
-117
No files matched your search
@@ -349,8 +349,60 @@ func (ctrl *LogsController) AppRanking(c *gin.Context) {
|
|||||||
limit = v
|
limit = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rows := services.GetAppRanking(uint(sourceID), time.Unix(0, from), time.Unix(0, to), limit)
|
rows := services.GetAppRanking(uint(sourceID), time.Unix(0, from), time.Unix(0, to), limit, c.DefaultQuery("sort", "bytes"))
|
||||||
utils.Success(c, gin.H{"list": rows, "hours": services.AppVolumeCoverage(uint(sourceID))})
|
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 限制)。
|
// DailyVolume 每日日志量历史(平台留存,不受 Loki retention 限制)。
|
||||||
|
|||||||
@@ -26,7 +26,15 @@ type AppVolumeSnapshot struct {
|
|||||||
SourceID uint `json:"sourceId"`
|
SourceID uint `json:"sourceId"`
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
App string `json:"app"`
|
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 该小时数据是否不完整(如平台停了一段时间后才回补,超出 Loki 保留期)
|
||||||
Partial bool `json:"partial"`
|
Partial bool `json:"partial"`
|
||||||
CapturedAt time.Time `json:"capturedAt"`
|
CapturedAt time.Time `json:"capturedAt"`
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ func SetupRouter() *gin.Engine {
|
|||||||
logs.POST("/context", logsCtrl.Context)
|
logs.POST("/context", logsCtrl.Context)
|
||||||
logs.GET("/labels", logsCtrl.Labels)
|
logs.GET("/labels", logsCtrl.Labels)
|
||||||
logs.GET("/label/:name/values", logsCtrl.LabelValues)
|
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)
|
logs.GET("/my-scope", logsCtrl.MyScope)
|
||||||
// ⚠️ 必须是 POST:前端 logsApi.histogram 用 POST 传 body(sourceId/query/start/end)。
|
// ⚠️ 必须是 POST:前端 logsApi.histogram 用 POST 传 body(sourceId/query/start/end)。
|
||||||
// 曾被误改成 GET,导致 POST /logs/histogram 全部 404,
|
// 曾被误改成 GET,导致 POST /logs/histogram 全部 404,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ func TestCriticalRoutes(t *testing.T) {
|
|||||||
{"GET", "/api/v1/logs/sources"},
|
{"GET", "/api/v1/logs/sources"},
|
||||||
{"GET", "/api/v1/logs/labels"},
|
{"GET", "/api/v1/logs/labels"},
|
||||||
{"GET", "/api/v1/logs/label/:name/values"},
|
{"GET", "/api/v1/logs/label/:name/values"},
|
||||||
|
// 批量 label 值(下拉联动一次请求)—— 路由写错就是前端全部下拉空
|
||||||
|
{"GET", "/api/v1/logs/label-values"},
|
||||||
{"GET", "/api/v1/logs/my-scope"},
|
{"GET", "/api/v1/logs/my-scope"},
|
||||||
{"GET", "/api/v1/logs/daily-volume"},
|
{"GET", "/api/v1/logs/daily-volume"},
|
||||||
{"GET", "/api/v1/logs/health"},
|
{"GET", "/api/v1/logs/health"},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"lokistack/models"
|
"lokistack/models"
|
||||||
@@ -36,7 +37,11 @@ const (
|
|||||||
type AppRankRow struct {
|
type AppRankRow struct {
|
||||||
App string `json:"app"`
|
App string `json:"app"`
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
Entries int64 `json:"entries"`
|
// Entries 日志条数(预聚合快照累加)
|
||||||
|
Entries int64 `json:"entries"`
|
||||||
|
// Bytes 日志体积(字节,预聚合快照累加)。
|
||||||
|
// 快照里没有体积的早期小时会让这个值偏小 —— 响应里的 bytesHours 用来提示覆盖率。
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
// FromHistory 该行来自平台预聚合(而不是实时扫日志)
|
// FromHistory 该行来自平台预聚合(而不是实时扫日志)
|
||||||
FromHistory bool `json:"fromHistory"`
|
FromHistory bool `json:"fromHistory"`
|
||||||
}
|
}
|
||||||
@@ -86,6 +91,7 @@ func CaptureAppVolumeForHour(src *models.LogSource, hour time.Time) error {
|
|||||||
type row struct {
|
type row struct {
|
||||||
ns, app string
|
ns, app string
|
||||||
entries int64
|
entries int64
|
||||||
|
bytes int64
|
||||||
}
|
}
|
||||||
rows := make([]row, 0, len(resp.Data.Result))
|
rows := make([]row, 0, len(resp.Data.Result))
|
||||||
for _, item := range resp.Data.Result {
|
for _, item := range resp.Data.Result {
|
||||||
@@ -114,6 +120,29 @@ func CaptureAppVolumeForHour(src *models.LogSource, hour time.Time) error {
|
|||||||
return nil
|
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()
|
db := repository.GetDB()
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return fmt.Errorf("数据库未初始化")
|
return fmt.Errorf("数据库未初始化")
|
||||||
@@ -128,6 +157,7 @@ func CaptureAppVolumeForHour(src *models.LogSource, hour time.Time) error {
|
|||||||
snap := &db.AppVolumeSnapshots[i]
|
snap := &db.AppVolumeSnapshots[i]
|
||||||
if snap.Hour == key && snap.SourceID == src.ID && snap.Namespace == r.ns && snap.App == r.app {
|
if snap.Hour == key && snap.SourceID == src.ID && snap.Namespace == r.ns && snap.App == r.app {
|
||||||
snap.Entries = r.entries
|
snap.Entries = r.entries
|
||||||
|
snap.Bytes = r.bytes
|
||||||
snap.CapturedAt = time.Now()
|
snap.CapturedAt = time.Now()
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
@@ -138,7 +168,7 @@ func CaptureAppVolumeForHour(src *models.LogSource, hour time.Time) error {
|
|||||||
}
|
}
|
||||||
db.AppVolumeSnapshots = append(db.AppVolumeSnapshots, models.AppVolumeSnapshot{
|
db.AppVolumeSnapshots = append(db.AppVolumeSnapshots, models.AppVolumeSnapshot{
|
||||||
ID: nextID, Hour: key, SourceID: src.ID, Namespace: r.ns, App: r.app,
|
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++
|
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 从**预聚合快照**里取指定时间范围的应用排行(毫秒级)。
|
// 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 {
|
if limit <= 0 {
|
||||||
limit = 10
|
limit = 10
|
||||||
}
|
}
|
||||||
@@ -231,6 +282,7 @@ func GetAppRanking(sourceID uint, from, to time.Time, limit int) []AppRankRow {
|
|||||||
type agg struct {
|
type agg struct {
|
||||||
ns, app string
|
ns, app string
|
||||||
entries int64
|
entries int64
|
||||||
|
bytes int64
|
||||||
}
|
}
|
||||||
sum := map[string]*agg{}
|
sum := map[string]*agg{}
|
||||||
for _, snap := range db.AppVolumeSnapshots {
|
for _, snap := range db.AppVolumeSnapshots {
|
||||||
@@ -244,16 +296,34 @@ func GetAppRanking(sourceID uint, from, to time.Time, limit int) []AppRankRow {
|
|||||||
sum[key] = item
|
sum[key] = item
|
||||||
}
|
}
|
||||||
item.entries += snap.Entries
|
item.entries += snap.Entries
|
||||||
|
item.bytes += snap.Bytes
|
||||||
}
|
}
|
||||||
rows := make([]AppRankRow, 0, len(sum))
|
rows := make([]AppRankRow, 0, len(sum))
|
||||||
for _, item := range 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 {
|
sort.Slice(rows, func(i, j int) bool {
|
||||||
if rows[i].Entries == rows[j].Entries {
|
a, b := rows[i], rows[j]
|
||||||
return rows[i].App < rows[j].App
|
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 {
|
if len(rows) > limit {
|
||||||
rows = rows[:limit]
|
rows = rows[:limit]
|
||||||
@@ -263,17 +333,32 @@ func GetAppRanking(sourceID uint, from, to time.Time, limit int) []AppRankRow {
|
|||||||
|
|
||||||
// AppVolumeCoverage 返回快照覆盖的小时数,前端用来提示"历史只有 N 小时数据"。
|
// AppVolumeCoverage 返回快照覆盖的小时数,前端用来提示"历史只有 N 小时数据"。
|
||||||
func AppVolumeCoverage(sourceID uint) int {
|
func AppVolumeCoverage(sourceID uint) int {
|
||||||
|
hours, _ := AppVolumeCoverageInfo(sourceID)
|
||||||
|
return hours
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppVolumeCoverageInfo 返回(快照覆盖小时数, 其中带体积的小时数)。
|
||||||
|
//
|
||||||
|
// 第二个值是给"按体积排行"用的诚实性提示:体积列是本轮新加的,
|
||||||
|
// 之前抓的小时只有条数没有体积,24h 排行里这些小时的体积会偏小。
|
||||||
|
// 两个数不一致时前端要明确告诉用户覆盖率,而不是让用户误读成"这个应用体积很小"。
|
||||||
|
func AppVolumeCoverageInfo(sourceID uint) (hours, bytesHours int) {
|
||||||
db := repository.GetDB()
|
db := repository.GetDB()
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return 0
|
return 0, 0
|
||||||
}
|
}
|
||||||
db.RLock()
|
db.RLock()
|
||||||
defer db.RUnlock()
|
defer db.RUnlock()
|
||||||
hours := map[string]bool{}
|
all := map[string]bool{}
|
||||||
|
withBytes := map[string]bool{}
|
||||||
for _, snap := range db.AppVolumeSnapshots {
|
for _, snap := range db.AppVolumeSnapshots {
|
||||||
if snap.SourceID == sourceID {
|
if snap.SourceID != sourceID {
|
||||||
hours[snap.Hour] = true
|
continue
|
||||||
|
}
|
||||||
|
all[snap.Hour] = true
|
||||||
|
if snap.Bytes > 0 {
|
||||||
|
withBytes[snap.Hour] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return len(hours)
|
return len(all), len(withBytes)
|
||||||
}
|
}
|
||||||
@@ -170,10 +170,10 @@ func upsertVolumeSnapshot(sourceID uint, date string, bytes int64, partial bool)
|
|||||||
// 实测 6ms(空闲)~ 20s(有并发查询时)。每 30 分钟 × 每一天都重跑一遍,
|
// 实测 6ms(空闲)~ 20s(有并发查询时)。每 30 分钟 × 每一天都重跑一遍,
|
||||||
// 既浪费 Loki 的 CPU,又会和用户页面上的查询抢同一台单实例的算力。所以:
|
// 既浪费 Loki 的 CPU,又会和用户页面上的查询抢同一台单实例的算力。所以:
|
||||||
//
|
//
|
||||||
// • 只处理 Date < 今天 的天(今天没结束,条数没有意义)
|
// - 只处理 Date < 今天 的天(今天没结束,条数没有意义)
|
||||||
// • 每天最多 entriesMaxAttempts 次,两次间隔 ≥ entriesRetryInterval
|
// - 每天最多 entriesMaxAttempts 次,两次间隔 ≥ entriesRetryInterval
|
||||||
// • 尝试次数**先写库再查**:进程中途被杀也不会退化成"每 30 分钟重试"
|
// - 尝试次数**先写库再查**:进程中途被杀也不会退化成"每 30 分钟重试"
|
||||||
// • 拿不到就留 0,前端图只画体积,不影响可读性
|
// - 拿不到就留 0,前端图只画体积,不影响可读性
|
||||||
func captureDailyEntries(source *models.LogSource) {
|
func captureDailyEntries(source *models.LogSource) {
|
||||||
if source == nil {
|
if source == nil {
|
||||||
return
|
return
|
||||||
@@ -311,16 +311,31 @@ func pruneDailyVolumeSnapshots() {
|
|||||||
today := dateKey(time.Now())
|
today := dateKey(time.Now())
|
||||||
db.Lock()
|
db.Lock()
|
||||||
defer db.Unlock()
|
defer db.Unlock()
|
||||||
|
known := make(map[uint]bool, len(db.LogSources))
|
||||||
|
for _, src := range db.LogSources {
|
||||||
|
known[src.ID] = true
|
||||||
|
}
|
||||||
changed := false
|
changed := false
|
||||||
kept := make([]models.DailyVolumeSnapshot, 0, len(db.DailyVolumeSnapshots))
|
kept := make([]models.DailyVolumeSnapshot, 0, len(db.DailyVolumeSnapshots))
|
||||||
|
droppedOrphan := 0
|
||||||
for _, snap := range db.DailyVolumeSnapshots {
|
for _, snap := range db.DailyVolumeSnapshots {
|
||||||
|
// 超过留存期
|
||||||
if snap.Date < cutoff {
|
if snap.Date < cutoff {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// 数据源已被删除:它的快照没有任何入口能查到,留着只会混淆取证
|
||||||
|
// (实测遇到过库里留着 id=2 的历史,查"到底抓没抓"时白查一轮)
|
||||||
|
if !known[snap.SourceID] {
|
||||||
|
droppedOrphan++
|
||||||
|
continue
|
||||||
|
}
|
||||||
kept = append(kept, snap)
|
kept = append(kept, snap)
|
||||||
}
|
}
|
||||||
|
if droppedOrphan > 0 {
|
||||||
|
log.Printf("[daily-volume] 清理已删除数据源的快照 %d 条", droppedOrphan)
|
||||||
|
}
|
||||||
if len(kept) != len(db.DailyVolumeSnapshots) {
|
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
|
db.DailyVolumeSnapshots = kept
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
@@ -414,10 +429,66 @@ func StartDailyVolumeCapture() {
|
|||||||
log.Printf("[daily-volume] 启动回补最近 %d 天", dailyVolumeBackfillDays)
|
log.Printf("[daily-volume] 启动回补最近 %d 天", dailyVolumeBackfillDays)
|
||||||
CaptureRecentDailyVolumes()
|
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)
|
ticker := time.NewTicker(30 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for range ticker.C {
|
midnight := time.NewTimer(time.Until(nextMidnightFinalize()))
|
||||||
CaptureRecentDailyVolumes()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,15 @@ func setupDailyVolumeDB(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { _ = repository.Shutdown() })
|
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 {
|
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: 3, Date: dateKey(now), SourceID: 1, Bytes: 10, Partial: true},
|
||||||
// 超过留存期 → 必须被清理
|
// 超过留存期 → 必须被清理
|
||||||
{ID: 4, Date: dateKey(expired), SourceID: 1, Bytes: 999},
|
{ID: 4, Date: dateKey(expired), SourceID: 1, Bytes: 999},
|
||||||
|
// 数据源已删除的孤儿快照 → 必须被清理
|
||||||
|
{ID: 5, Date: dateKey(yesterday), SourceID: 99, Bytes: 888},
|
||||||
}
|
}
|
||||||
db.Unlock()
|
db.Unlock()
|
||||||
|
|
||||||
@@ -65,6 +76,15 @@ func TestPruneDailyVolumeNormalizesPartialAndDropsExpired(t *testing.T) {
|
|||||||
if _, ok := got[dateKey(expired)]; ok {
|
if _, ok := got[dateKey(expired)]; ok {
|
||||||
t.Error("超过留存期的快照没有被清理")
|
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 {
|
if snap := got[dateKey(yesterday)]; snap.Partial {
|
||||||
t.Error("昨天结束之后抓的快照应标记为完整(partial=false)")
|
t.Error("昨天结束之后抓的快照应标记为完整(partial=false)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,6 +409,109 @@ func LokiLabelValues(ctx context.Context, src *models.LogSource, name, selector
|
|||||||
return resp.Data, nil
|
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)
|
// extractStreamSelector 从 LogQL 中提取 {...} 部分(labels API 仅接受 stream selector)
|
||||||
func extractStreamSelector(query string) string {
|
func extractStreamSelector(query string) string {
|
||||||
i := strings.Index(query, "{")
|
i := strings.Index(query, "{")
|
||||||
@@ -826,9 +929,12 @@ func pickHistogramStep(spanNano int64) string {
|
|||||||
return "1m"
|
return "1m"
|
||||||
case span <= 6*time.Hour:
|
case span <= 6*time.Hour:
|
||||||
return "5m"
|
return "5m"
|
||||||
case span <= 24*time.Hour:
|
case span <= 12*time.Hour:
|
||||||
return "15m"
|
return "15m"
|
||||||
case span <= 7*24*time.Hour:
|
case span <= 7*24*time.Hour:
|
||||||
|
// 12 小时以上统一 1 小时一桶。
|
||||||
|
// 24 小时原来是 15m(96 根柱子):横轴挤成一片、且每根柱子只有十来分钟的数据,
|
||||||
|
// 看不出趋势;1h → 24 根,和"按小时看日志量"的心智一致。
|
||||||
return "1h"
|
return "1h"
|
||||||
default:
|
default:
|
||||||
return "6h"
|
return "6h"
|
||||||
|
|||||||
@@ -4,12 +4,43 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
- **统计卡**:日志总量、错误行数、告警行数、活跃应用
|
- **统计卡**:日志总量、日志体积、错误行数、活跃应用
|
||||||
- **趋势折线**:总量 / 错误 / 告警随时间分布
|
- **趋势折线**:按时间分桶的日志体积(≤12 小时 15 分钟一桶,>12 小时 1 小时一桶)
|
||||||
- **命名空间分布**:环形图
|
- **应用排行**:按**日志体积**排 Top 10,支持 10/30/50 与前端分页,点击「查看日志」跳转查询页
|
||||||
- **应用流图**:Top 10 应用日志量堆叠
|
- **每日日志量**:平台留存历史,支持 7 / 30 / 90 / 365 天切换(独立于上方时间范围)
|
||||||
- **每日日志量**:近 7 天按日聚合(独立于时间范围筛选)
|
|
||||||
- **应用排行**:Top 10 应用,支持跳转查询页
|
|
||||||
- **Ingress 访问分析**:自动识别 ingress-nginx 访问日志,PV/UV、状态码、慢请求与接口排行;未安装 ingress-nginx 时自动折叠
|
- **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 天视角会出现大片空柱,容易被误读成「以前日志量很低」甚至「系统停摆过」
|
||||||
|
|
||||||
时间范围与数据源在页面顶部切换;指标查询经服务端聚合,开销远低于拉取原始日志。
|
时间范围与数据源在页面顶部切换;指标查询经服务端聚合,开销远低于拉取原始日志。
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
## 查询方式
|
## 查询方式
|
||||||
|
|
||||||
- **LogQL 编辑器**:语法高亮与标签/管道补全;回车执行。
|
- **LogQL 编辑器**:语法高亮与标签/管道补全;回车执行。
|
||||||
- **快捷查询**:选择命名空间缩小范围后,选中 App / Pod 即自动填入并执行;Ingress 访问日志按钮在选择 ingress 命名空间或 app 时出现。
|
- **快捷查询**:先选命名空间,再选 App / Pod(候选值**按级联懒加载**:选了命名空间才会去取 App 列表,选了 App 才取 Pod 列表,避免一进页面就把 Loki 索引扫一遍),选中即自动填入并执行;Ingress 访问日志按钮在选择 ingress 命名空间或 app 时出现。
|
||||||
- **Label 浏览器**:按标签与值点选构建查询;点结果行的标签值可快速加过滤。
|
- **Label 浏览器**:按标签与值点选构建查询;点结果行的标签值可快速加过滤。
|
||||||
- **Live Tail**:3 秒轮询最近 5 分钟,编辑查询自动防抖后切换。
|
- **Live Tail**:3 秒轮询最近 5 分钟,编辑查询自动防抖后切换。
|
||||||
|
|
||||||
|
|||||||
@@ -48,11 +48,19 @@ export const logsApi = {
|
|||||||
'/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 }) =>
|
appRanking: (sourceId: number, from: number, to: number, limit = 10, opts?: { signal?: AbortSignal; sort?: 'bytes' | 'entries' }) => {
|
||||||
http.get<{ list: Array<{ app: string; namespace: string; entries: number; fromHistory: boolean }>; hours: number }>(
|
const { signal, sort = 'bytes' } = opts ?? {}
|
||||||
`/logs/app-ranking?sourceId=${sourceId}&from=${from}&to=${to}&limit=${limit}`,
|
return http.get<{
|
||||||
opts,
|
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 }) =>
|
dailyVolume: (sourceId: number, days: number, opts?: { signal?: AbortSignal }) =>
|
||||||
@@ -63,6 +71,19 @@ export const logsApi = {
|
|||||||
metrics: (data: MetricQuery, opts?: { signal?: AbortSignal }) => http.post<MetricSeries[]>('/logs/metrics', data, { timeout: 125000, ...opts }),
|
metrics: (data: MetricQuery, opts?: { signal?: AbortSignal }) => http.post<MetricSeries[]>('/logs/metrics', data, { timeout: 125000, ...opts }),
|
||||||
labels: (sourceId: number, start?: number, end?: number) => http.get<string[]>(`/logs/labels${buildQuery({ sourceId, start, end } as { page?: number; pageSize?: number })}`),
|
labels: (sourceId: number, start?: number, end?: number) => http.get<string[]>(`/logs/labels${buildQuery({ sourceId, start, end } as { page?: number; pageSize?: number })}`),
|
||||||
labelValues: (sourceId: number, name: string, query = '', start?: number, end?: number) => http.get<string[]>(`/logs/label/${encodeURIComponent(name)}/values${buildQuery({ sourceId, query, start, end } as { page?: number; pageSize?: number })}`),
|
labelValues: (sourceId: number, name: string, query = '', start?: number, end?: number) => http.get<string[]>(`/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<Record<string, string[]>>(
|
||||||
|
`/logs/label-values${buildQuery({ sourceId, names: names.join(','), query, start, end } as { page?: number; pageSize?: number })}`,
|
||||||
|
opts,
|
||||||
|
),
|
||||||
/** 当前用户在某数据源上的授权范围(策略 labels;管理员 unrestricted=true) */
|
/** 当前用户在某数据源上的授权范围(策略 labels;管理员 unrestricted=true) */
|
||||||
myScope: (sourceId: number) => http.get<{ labels: Record<string, string>; unrestricted: boolean }>(`/logs/my-scope?sourceId=${sourceId}`),
|
myScope: (sourceId: number) => http.get<{ labels: Record<string, string>; unrestricted: boolean }>(`/logs/my-scope?sourceId=${sourceId}`),
|
||||||
policyGroups: () => http.get<LogPolicyGroup[]>('/logs/policy-groups'),
|
policyGroups: () => http.get<LogPolicyGroup[]>('/logs/policy-groups'),
|
||||||
|
|||||||
@@ -67,18 +67,20 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
table: {
|
table: {
|
||||||
showCount: 'Rows',
|
|
||||||
pagerInfo: '{{total}} apps · page {{page}}/{{pages}}',
|
pagerInfo: '{{total}} apps · page {{page}}/{{pages}}',
|
||||||
prevPage: 'Prev',
|
prevPage: 'Prev',
|
||||||
nextPage: 'Next',
|
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',
|
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: '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: '#',
|
rank: '#',
|
||||||
app: 'App',
|
app: 'App',
|
||||||
appWithKey: 'App ({{key}})',
|
appWithKey: 'App ({{key}})',
|
||||||
volume: 'Volume',
|
volume: 'Volume',
|
||||||
|
bytes: 'Volume',
|
||||||
|
entries: 'Entries',
|
||||||
share: 'Share',
|
share: 'Share',
|
||||||
errors: 'Errors',
|
errors: 'Errors',
|
||||||
errorRate: 'Error rate',
|
errorRate: 'Error rate',
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ export default {
|
|||||||
loadFailed: 'Load failed',
|
loadFailed: 'Load failed',
|
||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
noApp: 'No app in this scope',
|
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',
|
noPod: 'No pods under this app',
|
||||||
optional: 'Optional',
|
optional: 'Optional',
|
||||||
errorLogs: 'Errors (error)',
|
errorLogs: 'Errors (error)',
|
||||||
|
|||||||
@@ -67,18 +67,20 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
table: {
|
table: {
|
||||||
showCount: '显示条数',
|
|
||||||
pagerInfo: '共 {{total}} 个应用 · 第 {{page}}/{{pages}} 页',
|
pagerInfo: '共 {{total}} 个应用 · 第 {{page}}/{{pages}} 页',
|
||||||
prevPage: '上一页',
|
prevPage: '上一页',
|
||||||
nextPage: '下一页',
|
nextPage: '下一页',
|
||||||
subtitleHistory: '{{range}} 分钟范围超过了实时扫描上限,这里读平台按小时预聚合的快照(已留存 {{hours}} 小时)——毫秒级返回,但不含错误数与趋势。',
|
subtitleHistory: '{{range}} 分钟范围超过了实时扫描上限,这里读平台按小时预聚合的快照(已留存 {{hours}} 小时)——毫秒级返回,按体积排序,但不含错误数与趋势。',
|
||||||
|
bytesCoverage: '其中 {{bytesHours}}/{{hours}} 小时有体积数据,其余小时只有条数(早期快照),体积会偏小。',
|
||||||
title: '应用日志量排行',
|
title: '应用日志量排行',
|
||||||
subtitle: '按日志条数排前 10 的应用,点击「查看日志」跳转至查询页',
|
subtitle: '一次取前 50 个应用(占比按这 50 个合计计算),每页 10 条,点击「查看日志」跳转至查询页',
|
||||||
computing: '正在按应用统计(需读取日志内容,通常 20~30 秒)…',
|
computing: '正在按应用统计(需读取日志内容,通常 20~30 秒)…',
|
||||||
rank: '#',
|
rank: '#',
|
||||||
app: '应用',
|
app: '应用',
|
||||||
appWithKey: '应用({{key}})',
|
appWithKey: '应用({{key}})',
|
||||||
volume: '日志量',
|
volume: '日志量',
|
||||||
|
bytes: '体积',
|
||||||
|
entries: '条数',
|
||||||
share: '占比',
|
share: '占比',
|
||||||
errors: '错误量',
|
errors: '错误量',
|
||||||
errorRate: '错误率',
|
errorRate: '错误率',
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ export default {
|
|||||||
loadFailed: '加载失败',
|
loadFailed: '加载失败',
|
||||||
retry: '重试',
|
retry: '重试',
|
||||||
noApp: '该范围内无 app',
|
noApp: '该范围内无 app',
|
||||||
pickNsFirst: '先选命名空间可缩小范围',
|
pickNsFirst: '先选命名空间',
|
||||||
|
pickAppFirst: '先选 App',
|
||||||
noPod: '该 app 下无副本',
|
noPod: '该 app 下无副本',
|
||||||
optional: '可选',
|
optional: '可选',
|
||||||
errorLogs: '错误 error',
|
errorLogs: '错误 error',
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export const DailyVolumeChart = memo(function DailyVolumeChart({ points, days, o
|
|||||||
// 有留存的天数与"完全没留存"的天数分开统计:
|
// 有留存的天数与"完全没留存"的天数分开统计:
|
||||||
// 后者是"不知道",不能画成 0 让人以为那天没日志。
|
// 后者是"不知道",不能画成 0 让人以为那天没日志。
|
||||||
const covered = data.filter(item => !item.missing)
|
const covered = data.filter(item => !item.missing)
|
||||||
const missingCount = data.length - covered.length
|
|
||||||
const hasData = covered.some(item => item.bytes > 0)
|
const hasData = covered.some(item => item.bytes > 0)
|
||||||
const coverage = covered.length
|
const coverage = covered.length
|
||||||
|
|
||||||
@@ -102,13 +101,6 @@ export const DailyVolumeChart = memo(function DailyVolumeChart({ points, days, o
|
|||||||
<EmptyChart message={t('daily.empty')} />
|
<EmptyChart message={t('daily.empty')} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 灰柱 = 平台没有留存那天(不是"日志量为 0"),必须显式说明,
|
|
||||||
否则 30/90/365 天视角下会被误读成"以前日志很少" */}
|
|
||||||
{missingCount > 0 && (
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
|
||||||
{t('daily.missingHint', { count: missingCount })}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -2,7 +2,7 @@ import { memo } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import type { AppRow } from '../hooks/useAnalysisMetrics'
|
import type { AppRow } from '../hooks/useAnalysisMetrics'
|
||||||
import { compactNumber } from './chart-shared'
|
import { compactNumber, formatBytes } from './chart-shared'
|
||||||
|
|
||||||
/** 迷你趋势 sparkline:纯 SVG polyline,避免每行一个 recharts 实例 */
|
/** 迷你趋势 sparkline:纯 SVG polyline,避免每行一个 recharts 实例 */
|
||||||
function Sparkline({ values }: { values: number[] }) {
|
function Sparkline({ values }: { values: number[] }) {
|
||||||
@@ -31,11 +31,25 @@ export const TopAppsTable = memo(function TopAppsTable({
|
|||||||
return <p className="py-8 text-center text-sm text-muted-foreground">{t('table.empty')}</p>
|
return <p className="py-8 text-center text-sm text-muted-foreground">{t('table.empty')}</p>
|
||||||
}
|
}
|
||||||
const appHeader = appLabel === 'app' ? t('table.app') : t('table.appWithKey', { key: appLabel })
|
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 <div className="max-h-[26rem] overflow-auto rounded-lg border">
|
return <div className="max-h-[26rem] overflow-auto rounded-lg border">
|
||||||
<table className="w-full text-left text-xs">
|
<table className="w-full text-left text-xs">
|
||||||
<thead className="sticky top-0 bg-muted text-muted-foreground">
|
<thead className="sticky top-0 bg-muted text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
{[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 => (
|
||||||
<th key={title} className="whitespace-nowrap border-b px-3 py-2 font-medium">{title}</th>
|
<th key={title} className="whitespace-nowrap border-b px-3 py-2 font-medium">{title}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -43,12 +57,18 @@ export const TopAppsTable = memo(function TopAppsTable({
|
|||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
{apps.map((row, index) => {
|
{apps.map((row, index) => {
|
||||||
const selector = `{namespace="${row.namespace}", ${appLabel}="${row.app}"}`
|
const selector = `{namespace="${row.namespace}", ${appLabel}="${row.app}"}`
|
||||||
|
const metric = metricOf(row)
|
||||||
return <tr key={`${row.app}-${row.namespace}`} className="hover:bg-muted/40">
|
return <tr key={`${row.app}-${row.namespace}`} className="hover:bg-muted/40">
|
||||||
<td className="px-3 py-2 tabular-nums text-muted-foreground">{index + 1}</td>
|
<td className="px-3 py-2 tabular-nums text-muted-foreground">{index + 1}</td>
|
||||||
<td className="max-w-56 truncate px-3 py-2 font-medium" title={row.app || undefined}>{row.app || t('table.untagged')}</td>
|
<td className="max-w-56 truncate px-3 py-2 font-medium" title={row.app || undefined}>{row.app || t('table.untagged')}</td>
|
||||||
<td className="max-w-40 truncate px-3 py-2 text-muted-foreground" title={row.namespaces.join(', ')}>{row.namespace || '—'}</td>
|
<td className="max-w-40 truncate px-3 py-2 text-muted-foreground" title={row.namespaces.join(', ')}>{row.namespace || '—'}</td>
|
||||||
<td className="px-3 py-2 tabular-nums">{compactNumber(row.total)} 条</td>
|
{useBytes
|
||||||
<td className="px-3 py-2 tabular-nums text-muted-foreground">{total > 0 ? `${((row.total / total) * 100).toFixed(1)}%` : '—'}</td>
|
? <>
|
||||||
|
<td className="px-3 py-2 tabular-nums">{formatBytes(row.bytes ?? 0)}</td>
|
||||||
|
<td className="px-3 py-2 tabular-nums text-muted-foreground">{compactNumber(row.total)} 条</td>
|
||||||
|
</>
|
||||||
|
: <td className="px-3 py-2 tabular-nums">{compactNumber(row.total)} 条</td>}
|
||||||
|
<td className="px-3 py-2 tabular-nums text-muted-foreground">{metricTotal > 0 ? `${((metric / metricTotal) * 100).toFixed(1)}%` : '—'}</td>
|
||||||
<td className={`px-3 py-2 tabular-nums ${row.errorCount > 0 ? 'text-destructive' : 'text-muted-foreground'}`}>{compactNumber(row.errorCount)} 条</td>
|
<td className={`px-3 py-2 tabular-nums ${row.errorCount > 0 ? 'text-destructive' : 'text-muted-foreground'}`}>{compactNumber(row.errorCount)} 条</td>
|
||||||
<td className="px-3 py-2 tabular-nums text-muted-foreground">{row.total > 0 ? `${((row.errorCount / row.total) * 100).toFixed(2)}%` : '—'}</td>
|
<td className="px-3 py-2 tabular-nums text-muted-foreground">{row.total > 0 ? `${((row.errorCount / row.total) * 100).toFixed(2)}%` : '—'}</td>
|
||||||
<td className="px-3 py-2"><Sparkline values={row.sparkline} /></td>
|
<td className="px-3 py-2"><Sparkline values={row.sparkline} /></td>
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ export interface AppRow {
|
|||||||
app: string
|
app: string
|
||||||
namespace: string
|
namespace: string
|
||||||
namespaces: string[]
|
namespaces: string[]
|
||||||
|
/** 日志条数 */
|
||||||
total: number
|
total: number
|
||||||
|
/**
|
||||||
|
* 日志体积(字节)。只有"长范围读平台预聚合快照"这条路径有值;
|
||||||
|
* 短范围实时扫描只有条数(按 app 取体积要再扫一遍日志内容,代价太大),
|
||||||
|
* 所以这里是可选,表格按"有没有体积"决定展示哪一列。
|
||||||
|
*/
|
||||||
|
bytes?: number
|
||||||
errorCount: number
|
errorCount: number
|
||||||
sparkline: number[]
|
sparkline: number[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,14 +160,21 @@ const AnalysisPage = memo(function AnalysisPage() {
|
|||||||
Math.round((new Date(end).getTime() - new Date(start).getTime()) / 60000),
|
Math.round((new Date(end).getTime() - new Date(start).getTime()) / 60000),
|
||||||
)
|
)
|
||||||
const useHistoryRanking = rangeMinutes > 60
|
const useHistoryRanking = rangeMinutes > 60
|
||||||
// 排行一次取多少(10/30/50)。topk 只影响返回序列数,扫描成本与 N
|
// 排行**一次取 50 条**,前端按 10 条/页切。
|
||||||
// 无关(实测 10/30/50/100 都在 7~18 秒同一区间),所以放宽不增加查询成本。
|
//
|
||||||
// 分页在前端做:排行是"排序后的静态列表",翻页不需要再查 Loki。
|
// 为什么不做 10/30/50 选择器:
|
||||||
const [rankLimit, setRankLimit] = useState(10)
|
// topk 只决定最后返回多少序列,扫描的 chunk 集合完全相同(实测
|
||||||
const [rankPage, setRankPage] = useState(1)
|
// 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 RANK_PAGE_SIZE = 10
|
||||||
|
const [rankPage, setRankPage] = useState(1)
|
||||||
const [historyApps, setHistoryApps] = useState<AppRow[] | null>(null)
|
const [historyApps, setHistoryApps] = useState<AppRow[] | null>(null)
|
||||||
const [historyHours, setHistoryHours] = useState(0)
|
const [historyHours, setHistoryHours] = useState(0)
|
||||||
|
/** 其中"有体积数据"的小时数(体积列是本轮新增,早期快照只有条数) */
|
||||||
|
const [historyBytesHours, setHistoryBytesHours] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!useHistoryRanking || !effectiveSourceId) {
|
if (!useHistoryRanking || !effectiveSourceId) {
|
||||||
@@ -177,8 +184,11 @@ const AnalysisPage = memo(function AnalysisPage() {
|
|||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
setHistoryApps(null)
|
setHistoryApps(null)
|
||||||
logsApi
|
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,
|
signal: controller.signal,
|
||||||
|
// 排行按体积:用户问的是"哪个应用把日志量吃掉了",
|
||||||
|
// 条数会被高频短日志主导(一条 INFO 刷屏 vs 一条大 JSON)。
|
||||||
|
sort: 'bytes',
|
||||||
})
|
})
|
||||||
.then(res => {
|
.then(res => {
|
||||||
const rows: AppRow[] = (res?.list ?? []).map(item => ({
|
const rows: AppRow[] = (res?.list ?? []).map(item => ({
|
||||||
@@ -186,16 +196,18 @@ const AnalysisPage = memo(function AnalysisPage() {
|
|||||||
namespace: item.namespace,
|
namespace: item.namespace,
|
||||||
namespaces: item.namespace ? [item.namespace] : [],
|
namespaces: item.namespace ? [item.namespace] : [],
|
||||||
total: item.entries,
|
total: item.entries,
|
||||||
// 预聚合只统计条数,没有错误数/趋势 —— 表格对应列会显示 —
|
bytes: item.bytes,
|
||||||
|
// 预聚合只统计条数/体积,没有错误数/趋势 —— 表格对应列会显示 —
|
||||||
errorCount: 0,
|
errorCount: 0,
|
||||||
sparkline: [],
|
sparkline: [],
|
||||||
}))
|
}))
|
||||||
setHistoryApps(rows)
|
setHistoryApps(rows)
|
||||||
setHistoryHours(res?.hours ?? 0)
|
setHistoryHours(res?.hours ?? 0)
|
||||||
|
setHistoryBytesHours(res?.bytesHours ?? 0)
|
||||||
})
|
})
|
||||||
.catch(() => setHistoryApps([]))
|
.catch(() => setHistoryApps([]))
|
||||||
return () => controller.abort()
|
return () => controller.abort()
|
||||||
}, [useHistoryRanking, effectiveSourceId, start, end, rankLimit])
|
}, [useHistoryRanking, effectiveSourceId, start, end])
|
||||||
|
|
||||||
const { data, loading, appsLoading, error, appLabel, refresh } = useAnalysisMetrics({
|
const { data, loading, appsLoading, error, appLabel, refresh } = useAnalysisMetrics({
|
||||||
sourceId: effectiveSourceId,
|
sourceId: effectiveSourceId,
|
||||||
@@ -207,7 +219,7 @@ const AnalysisPage = memo(function AnalysisPage() {
|
|||||||
// 用户滚到排行卡才发那条 10~15 秒的重查询。
|
// 用户滚到排行卡才发那条 10~15 秒的重查询。
|
||||||
// 这是真正减少查询压力的一步——之前图在首屏,所以只能全量查。
|
// 这是真正减少查询压力的一步——之前图在首屏,所以只能全量查。
|
||||||
rankingEnabled: !useHistoryRanking && (rankingView.inView || forceRanking),
|
rankingEnabled: !useHistoryRanking && (rankingView.inView || forceRanking),
|
||||||
rankLimit,
|
rankLimit: RANK_FETCH_LIMIT,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 选择值与已生效值不一致 → 提示"点刷新生效"
|
// 选择值与已生效值不一致 → 提示"点刷新生效"
|
||||||
@@ -363,25 +375,6 @@ const AnalysisPage = memo(function AnalysisPage() {
|
|||||||
{/* 副标题说明这块不需要读日志内容,避免和上面的排行混淆 */}
|
{/* 副标题说明这块不需要读日志内容,避免和上面的排行混淆 */}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{/* 一次取多少个应用:点击即触发查询(topk 放宽不增加扫描成本) */}
|
|
||||||
<div className="mb-2 flex items-center gap-1.5">
|
|
||||||
<span className="text-xs text-muted-foreground">{t('table.showCount')}</span>
|
|
||||||
{[10, 30, 50].map(count => (
|
|
||||||
<Button
|
|
||||||
key={count}
|
|
||||||
size="sm"
|
|
||||||
variant={rankLimit === count ? 'default' : 'outline'}
|
|
||||||
className="h-7 px-2.5 text-xs"
|
|
||||||
onClick={() => {
|
|
||||||
setRankLimit(count)
|
|
||||||
setRankPage(1)
|
|
||||||
refresh()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{(useHistoryRanking ? historyApps === null : (appsLoading || loading)) && !(useHistoryRanking ? (historyApps ?? []) : (data?.apps ?? [])).length ? (
|
{(useHistoryRanking ? historyApps === null : (appsLoading || loading)) && !(useHistoryRanking ? (historyApps ?? []) : (data?.apps ?? [])).length ? (
|
||||||
// 这块必须按 app 分组统计(读日志内容),实测大集群 15 分钟约 20~30 秒。
|
// 这块必须按 app 分组统计(读日志内容),实测大集群 15 分钟约 20~30 秒。
|
||||||
// 明确告诉用户在算什么、要等多久,比空表格或整页转圈好。
|
// 明确告诉用户在算什么、要等多久,比空表格或整页转圈好。
|
||||||
@@ -392,13 +385,20 @@ const AnalysisPage = memo(function AnalysisPage() {
|
|||||||
) : (
|
) : (
|
||||||
<TopAppsTable
|
<TopAppsTable
|
||||||
apps={rankPageRows}
|
apps={rankPageRows}
|
||||||
/* 分母必须是"条数合计":apps 里是条数,stats.total 是字节,
|
/* 分母必须是同一口径的合计:按体积排行时用体积合计,
|
||||||
混用会让占比全部算成 0.0x% */
|
按条数排行时用条数合计,混用会让占比全部算成 0.0x% */
|
||||||
total={rankTotal}
|
total={rankTotal}
|
||||||
appLabel={appLabel}
|
appLabel={appLabel}
|
||||||
onViewLogs={query => navigate(`/logs?q=${encodeURIComponent(query)}`)}
|
onViewLogs={query => navigate(`/logs?q=${encodeURIComponent(query)}`)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* 体积覆盖率提示:体积列是本轮新增,早期小时只有条数。
|
||||||
|
不提示的话用户会把"体积偏小"误读成"这个应用体积真的很小"。 */}
|
||||||
|
{useHistoryRanking && historyHours > 0 && historyBytesHours < historyHours && (
|
||||||
|
<p className="mt-2 text-xs text-warning">
|
||||||
|
{t('table.bytesCoverage', { bytesHours: historyBytesHours, hours: historyHours })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{/* 分页在前端做:排行是排序后的静态列表,翻页不需要再查 Loki。
|
{/* 分页在前端做:排行是排序后的静态列表,翻页不需要再查 Loki。
|
||||||
服务端只按 topk(N) 返回一次,N=50 时 5 页。 */}
|
服务端只按 topk(N) 返回一次,N=50 时 5 页。 */}
|
||||||
{rankPageCount > 1 && (
|
{rankPageCount > 1 && (
|
||||||
|
|||||||
@@ -25,16 +25,49 @@ export function AccessRuleEditor({ sourceId, username, labels, onChange }: Acces
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sourceId) return
|
if (!sourceId) return
|
||||||
let active = true
|
let active = true
|
||||||
const load = (name: PolicyLabel, scope: Record<string, string>) => {
|
const scopeSelector = (scope: Record<string, string>) =>
|
||||||
setLoading(current => ({ ...current, [name]: true }))
|
Object.keys(scope).length
|
||||||
logsApi.labelValues(sourceId, name, Object.keys(scope).length ? `{${Object.entries(scope).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(',')}}` : '')
|
? `{${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 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<string, string> = 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', {})
|
setBusy(['pod'], true)
|
||||||
load('app', namespace ? { namespace } : {})
|
logsApi
|
||||||
load('pod', { ...(namespace ? { namespace } : {}), ...(app ? { app } : {}) })
|
.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 }
|
return () => { active = false }
|
||||||
}, [sourceId, namespace, app])
|
}, [sourceId, namespace, app])
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, onInsert,
|
|||||||
const [namespaceError, setNamespaceError] = useState('')
|
const [namespaceError, setNamespaceError] = useState('')
|
||||||
/** 重试计数:失败后点「重试」时 +1 触发重新拉取 */
|
/** 重试计数:失败后点「重试」时 +1 触发重新拉取 */
|
||||||
const [reloadKey, setReloadKey] = useState(0)
|
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')
|
const [appLabel, setAppLabel] = useState('app')
|
||||||
/** 授权策略里配置的 namespace(如默认策略的 default),用作默认选中值 */
|
/** 授权策略里配置的 namespace(如默认策略的 default),用作默认选中值 */
|
||||||
const [scopeNamespace, setScopeNamespace] = useState('')
|
const [scopeNamespace, setScopeNamespace] = useState('')
|
||||||
@@ -103,43 +108,59 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, onInsert,
|
|||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [sourceId, reloadKey])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!sourceId) return
|
if (!sourceId || !namespace.trim()) {
|
||||||
|
setApps([])
|
||||||
|
setAppLabel('app')
|
||||||
|
setAppLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
let active = true
|
let active = true
|
||||||
|
setAppLoading(true)
|
||||||
const selector = selectorOf({ namespace })
|
const selector = selectorOf({ namespace })
|
||||||
const fetchLabel = (name: string) =>
|
logsApi
|
||||||
logsApi.labelValues(sourceId, name, selector)
|
.labelValuesBatch(sourceId, ['app', 'service_name', 'container'], selector)
|
||||||
.then(list => (Array.isArray(list) ? list : []))
|
.then(result => {
|
||||||
.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 }) => {
|
|
||||||
if (!active) return
|
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)
|
setAppLabel(label)
|
||||||
setApps(list)
|
setApps(list)
|
||||||
})
|
})
|
||||||
.catch(() => { if (active) { setAppLabel('app'); setApps([]) } })
|
.catch(() => { if (active) { setAppLabel('app'); setApps([]) } })
|
||||||
|
.finally(() => { if (active) setAppLoading(false) })
|
||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [sourceId, namespace])
|
}, [sourceId, namespace])
|
||||||
|
|
||||||
// pod 候选:按 namespace + app 收窄
|
// pod 候选:同样懒加载 —— 没选 app 就不拉(一个命名空间几百个 Pod 的下拉毫无用处)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sourceId) return
|
if (!sourceId || !app.trim()) {
|
||||||
|
setPods([])
|
||||||
|
setPodLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
let active = true
|
let active = true
|
||||||
|
setPodLoading(true)
|
||||||
// 用实际生效的 appLabel 收窄;否则在没打 app 标签的集群上筛不出东西
|
// 用实际生效的 appLabel 收窄;否则在没打 app 标签的集群上筛不出东西
|
||||||
const selector = selectorOf(appLabel === 'app' ? { namespace, app } : { namespace, [appLabel]: app })
|
const selector = selectorOf(appLabel === 'app' ? { namespace, app } : { namespace, [appLabel]: app })
|
||||||
logsApi.labelValues(sourceId, 'pod', selector)
|
logsApi.labelValues(sourceId, 'pod', selector)
|
||||||
.then(next => { if (active) setPods(Array.isArray(next) ? next : []) })
|
.then(next => { if (active) setPods(Array.isArray(next) ? next : []) })
|
||||||
.catch(() => { if (active) setPods([]) })
|
.catch(() => { if (active) setPods([]) })
|
||||||
|
.finally(() => { if (active) setPodLoading(false) })
|
||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [sourceId, namespace, app, appLabel])
|
}, [sourceId, namespace, app, appLabel])
|
||||||
|
|
||||||
@@ -258,18 +279,36 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, onInsert,
|
|||||||
values={apps}
|
values={apps}
|
||||||
value={app}
|
value={app}
|
||||||
onChange={chooseApp}
|
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
|
allowEmpty
|
||||||
disabled={!sourceId}
|
loading={appLoading}
|
||||||
|
disabled={!sourceId || !namespace.trim()}
|
||||||
/>
|
/>
|
||||||
<SearchableValueSelect
|
<SearchableValueSelect
|
||||||
label={t('templates.pod')}
|
label={t('templates.pod')}
|
||||||
values={pods}
|
values={pods}
|
||||||
value={pod}
|
value={pod}
|
||||||
onChange={choosePod}
|
onChange={choosePod}
|
||||||
placeholder={pods.length ? t('templates.selectCount', { count: pods.length }) : app ? t('templates.noPod') : t('templates.optional')}
|
placeholder={
|
||||||
|
podLoading
|
||||||
|
? t('templates.loading')
|
||||||
|
: pods.length
|
||||||
|
? t('templates.selectCount', { count: pods.length })
|
||||||
|
: app
|
||||||
|
? t('templates.noPod')
|
||||||
|
: t('templates.pickAppFirst')
|
||||||
|
}
|
||||||
allowEmpty
|
allowEmpty
|
||||||
disabled={!sourceId}
|
loading={podLoading}
|
||||||
|
disabled={!sourceId || !app.trim()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
|||||||
Reference in new issue
Block a user