feat(logs): 分页显示总页数(新增 /logs/stats,走 index/stats 只读索引)

用户反馈:底部只有「上一页 第 1 页 下一页」,看不到末尾/总页数。

原因:日志结果是 Loki 游标分页(cursor = 纳秒时间戳),本身不返回总数,
前端只能知道"有没有下一页",算不出总页数。

解法:新增 POST /api/v1/logs/stats
  → 调 Loki /loki/api/v1/index/stats(只读 TSDB 索引里的 chunk 元数据)
  → 返回 {streams, chunks, bytes, entries}
  → 前端用 entries / limit 得到总页数
实测成本极低:1h 51ms、24h 30ms(3.28 亿条 / 44.2 GB)。

改动:
1. backend: services.LokiStatsFor + controllers.LogsController.Stats + 路由 /logs/stats
2. frontend: logsApi.stats;查询成功后拉取总数(复用同一 AbortController,
   失败静默不影响日志展示);计算 totalPages 传给结果面板
3. LogResultsPanel: 有总数时显示「第 N / M 页」,否则维持「第 N 页」
4. i18n: 新增 results.pageOfTotal(中英)

镜像:lghost/lokistack:latest + v0.1 = sha256:41d6576ec232
This commit is contained in:
opsx committed 2026-10-28 16:02:35 +00:00
1 parent de376a1284
commit 5d530036e6
8 files changed
+88 -5

No files matched your search

+25
View File
@@ -297,6 +297,31 @@ func (ctrl *LogsController) Health(c *gin.Context) {
utils.Success(c, gin.H{"ready": services.LokiReady(ctx, src)})
}
// Stats 返回该查询在时间范围内的「总条数/字节/块数」,用于前端展示总页数。
// 走 index/stats(只读索引),成本远低于真数日志。
func (ctrl *LogsController) Stats(c *gin.Context) {
var req dto.LogQueryRequest
if !utils.BindAndValidate(c, &req) {
return
}
src := services.GetLogSource(req.SourceID)
if src == nil {
utils.BadRequest(c, "数据源不存在")
return
}
query, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", req.Query)
if err != nil {
utils.Forbidden(c, err.Error())
return
}
st, err := services.LokiStatsFor(c.Request.Context(), src, query, req.Start, req.End)
if err != nil {
utils.Error(c, 502, err.Error())
return
}
utils.Success(c, st)
}
func (ctrl *LogsController) Histogram(c *gin.Context) {
var req dto.LogQueryRequest
if !utils.BindAndValidate(c, &req) {
+2
View File
@@ -91,6 +91,8 @@ func SetupRouter() *gin.Engine {
logs.GET("/label/:name/values", logsCtrl.LabelValues)
logs.GET("/my-scope", logsCtrl.MyScope)
logs.POST("/histogram", logsCtrl.Histogram)
// 总量统计(index/stats):只读索引,用于算总页数
logs.POST("/stats", logsCtrl.Stats)
logs.POST("/metrics", middleware.AdminAuth(), logsCtrl.Metrics)
logs.GET("/health", logsCtrl.Health)
}
+38
View File
@@ -544,6 +544,44 @@ func lokiHistogramByCount(ctx context.Context, src *models.LogSource, query, ste
return buckets, nil
}
// LokiStats 用官方 /loki/api/v1/index/stats 取「条数/字节/块数」。
//
// 为什么不数日志条数:index/stats 只读 TSDB 索引里的 chunk 元数据,
// 不解码日志内容。实测同一 app 24 小时:count_over_time 53s 失败,
// index/stats 1.3s。用它来算「共 N 条 / 共 M 页」成本可以忽略。
type LokiStats struct {
Streams int64 `json:"streams"`
Chunks int64 `json:"chunks"`
Bytes int64 `json:"bytes"`
Entries int64 `json:"entries"`
}
func LokiStatsFor(ctx context.Context, src *models.LogSource, query string, start, end int64) (*LokiStats, 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", query)
q.Set("start", strconv.FormatInt(start, 10))
q.Set("end", strconv.FormatInt(end, 10))
body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/index/stats", q)
if err != nil {
return nil, err
}
var st LokiStats
if err := json.Unmarshal(body, &st); err != nil {
return nil, err
}
return &st, nil
}
// MetricSeriesPoint 指标序列上的一个采样点
type MetricSeriesPoint struct {
Timestamp string `json:"timestamp"`