Files
LokiStack/backend/controllers/logs.go
T
opsx 5d530036e6 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
2026-10-28 16:02:35 +00:00

379 lines
11 KiB
Go

package controllers
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
"lokistack/dto"
"lokistack/models"
"lokistack/services"
"lokistack/utils"
"github.com/gin-gonic/gin"
)
func (ctrl *LogsController) Context(c *gin.Context) {
var req dto.LogContextRequest
if !utils.BindAndValidate(c, &req) {
return
}
src := services.GetLogSource(req.SourceID)
if src == nil {
utils.BadRequest(c, "数据源不存在")
return
}
ts, err := strconv.ParseInt(req.TimestampNs, 10, 64)
if err != nil || ts < 1 {
utils.BadRequest(c, "时间戳无效")
return
}
selector, err := services.ContextLogSelector(req.Labels)
if err != nil {
utils.BadRequest(c, err.Error())
return
}
query, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", selector)
if err != nil {
utils.Forbidden(c, err.Error())
return
}
span := int64(5 * time.Minute)
before, err := services.QueryLokiLogs(c.Request.Context(), src, query, ts-span, ts-1, 5, "backward", "")
if err != nil {
utils.Error(c, 502, err.Error())
return
}
after, err := services.QueryLokiLogs(c.Request.Context(), src, query, ts+1, ts+span, 5, "forward", "")
if err != nil {
utils.Error(c, 502, err.Error())
return
}
utils.Success(c, gin.H{"before": before.Entries, "after": after.Entries})
}
// LogsController 日志查询代理 + 数据源管理
type LogsController struct{}
func NewLogsController() *LogsController {
return &LogsController{}
}
// ==================== 数据源 ====================
func (ctrl *LogsController) ListSources(c *gin.Context) {
if c.GetString("role") == "admin" {
services.EnsureLogSourcesForRunningDeployments()
}
list := services.ListVisibleLogSources(c.GetUint("userId"), c.GetString("role") == "admin")
utils.Success(c, utils.PageData{List: list, Total: int64(len(list)), Page: 1, PageSize: 100})
}
func (ctrl *LogsController) CreateSource(c *gin.Context) {
var req dto.LogSourceRequest
if !utils.BindAndValidate(c, &req) {
return
}
if req.Name == "" || req.TargetType == "" {
utils.BadRequest(c, "名称与类型必填")
return
}
if err := services.ValidateSourceAccess(req.Access); err != nil {
utils.BadRequest(c, err.Error())
return
}
src := &models.LogSource{
Name: req.Name, TargetType: req.TargetType,
Namespace: req.Namespace, Service: req.Service, Port: req.Port,
URL: req.URL, AuthType: req.AuthType, BasicUser: req.BasicUser,
BasicPass: req.BasicPass, BearerToken: req.BearerToken, Headers: req.Headers, SkipTLSVerify: req.SkipTLSVerify, IsDefault: req.IsDefault, Access: req.Access,
}
if req.TargetType == "cluster-svc" {
if req.ClusterID > 0 {
id := req.ClusterID
src.ClusterID = &id
} else {
utils.BadRequest(c, "cluster-svc 类型必须选择集群")
return
}
}
created, err := services.CreateLogSource(src)
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, created.Sanitized())
}
func (ctrl *LogsController) UpdateSource(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
var updates map[string]interface{}
if !utils.BindAndValidate(c, &updates) {
return
}
if src := services.GetLogSource(uint(id)); src != nil && src.Managed && updates["isDefault"] == nil {
utils.BadRequest(c, "平台部署的数据源由系统管理,不可编辑")
return
}
if err := services.UpdateLogSource(uint(id), updates); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "updated"})
}
func (ctrl *LogsController) DeleteSource(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
if src := services.GetLogSource(uint(id)); src != nil && src.Managed {
utils.BadRequest(c, "平台部署的数据源随部署管理,请先删除对应部署")
return
}
if err := services.DeleteLogSource(uint(id)); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "deleted"})
}
// TestSource 数据源连通性测试(按 id 或表单参数)
func (ctrl *LogsController) TestSource(c *gin.Context) {
var req dto.LogSourceRequest
if !utils.BindAndValidate(c, &req) {
return
}
var src *models.LogSource
if req.ID > 0 {
src = services.GetLogSource(req.ID)
if src == nil {
utils.NotFound(c, "数据源不存在")
return
}
} else {
src = &models.LogSource{
TargetType: req.TargetType, Namespace: req.Namespace, Service: req.Service,
Port: req.Port, URL: req.URL, AuthType: req.AuthType,
BasicUser: req.BasicUser, BasicPass: req.BasicPass, BearerToken: req.BearerToken, Headers: req.Headers, SkipTLSVerify: req.SkipTLSVerify,
}
if req.TargetType == "cluster-svc" && req.ClusterID > 0 {
id := req.ClusterID
src.ClusterID = &id
}
}
utils.Success(c, services.TestLogSource(c.Request.Context(), src))
}
// ==================== 查询代理 ====================
func (ctrl *LogsController) Query(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
}
result, err := services.QueryLokiLogs(c.Request.Context(), src, query,
req.Start, req.End, req.Limit, req.Direction, req.Cursor)
if err != nil {
utils.Error(c, 502, err.Error())
return
}
utils.Success(c, result)
}
func (ctrl *LogsController) Labels(c *gin.Context) {
id, _ := strconv.ParseUint(c.Query("sourceId"), 10, 64)
src := services.GetLogSource(uint(id))
if src == nil {
utils.BadRequest(c, "数据源不存在")
return
}
if _, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", "{}"); err != nil {
utils.Forbidden(c, err.Error())
return
}
start, _ := strconv.ParseInt(c.Query("start"), 10, 64)
end, _ := strconv.ParseInt(c.Query("end"), 10, 64)
labels, err := services.LokiLabels(c.Request.Context(), src, start, end)
if err != nil {
utils.Error(c, 502, err.Error())
return
}
utils.Success(c, labels)
}
func (ctrl *LogsController) LabelValues(c *gin.Context) {
id, _ := strconv.ParseUint(c.Query("sourceId"), 10, 64)
src := services.GetLogSource(uint(id))
if src == nil {
utils.BadRequest(c, "数据源不存在")
return
}
start, _ := strconv.ParseInt(c.Query("start"), 10, 64)
end, _ := strconv.ParseInt(c.Query("end"), 10, 64)
requestedQuery := c.Query("query")
if strings.TrimSpace(requestedQuery) == "" {
requestedQuery = "{}"
}
query, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", requestedQuery)
if err != nil {
utils.Forbidden(c, err.Error())
return
}
values, err := services.LokiLabelValues(c.Request.Context(), src,
c.Param("name"), query, start, end)
if err != nil {
utils.Error(c, 502, err.Error())
return
}
utils.Success(c, values)
}
// MyScope 返回当前登录用户在某数据源上的授权范围。
//
// 快捷 LogQL 用它做两件事:
// 1. 把授权策略里配置的 namespace 作为默认选中值(管理员配了 default/astralx 后开箱即用)
// 2. 把该值合并进 namespace 候选列表 —— 否则像 default 这种「策略里有、Loki 里没日志」
// 的命名空间不会出现在下拉里,用户会以为策略没生效
func (ctrl *LogsController) MyScope(c *gin.Context) {
id, _ := strconv.ParseUint(c.Query("sourceId"), 10, 64)
src := services.GetLogSource(uint(id))
if src == nil {
utils.BadRequest(c, "数据源不存在")
return
}
if c.GetString("role") == "admin" {
// 管理员的查询范围不受策略限制(unrestricted=true),
// 但仍返回策略里的 labels 作为快捷 LogQL 的默认选中值——
// 否则管理员在小黑框里看到的下拉永远没有策略配的那个 namespace,
// 会误以为「默认策略没生效」。
labels := services.UserAccessLabels(src, c.GetUint("userId"))
if labels == nil {
labels = map[string]string{}
}
utils.Success(c, gin.H{"labels": labels, "unrestricted": true})
return
}
labels := services.UserAccessLabels(src, c.GetUint("userId"))
if labels == nil {
utils.Forbidden(c, "无权查询此数据源")
return
}
utils.Success(c, gin.H{"labels": labels, "unrestricted": len(labels) == 0})
}
// Health Loki /ready 探活(查询页状态点)
func (ctrl *LogsController) Health(c *gin.Context) {
id, _ := strconv.ParseUint(c.Query("sourceId"), 10, 64)
src := services.GetLogSource(uint(id))
if src == nil {
utils.BadRequest(c, "数据源不存在")
return
}
if _, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", "{}"); err != nil {
utils.Forbidden(c, err.Error())
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
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) {
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
}
buckets, err := services.LokiHistogram(c.Request.Context(), src, query, req.Start, req.End)
if err != nil {
switch {
case errors.Is(err, services.ErrTooManyQueries):
// 429:并发重查询超限,让前端提示"稍后重试",而不是当成服务故障
utils.Error(c, http.StatusTooManyRequests, err.Error())
case strings.Contains(err.Error(), "统计范围过大"):
utils.Error(c, http.StatusBadRequest, err.Error())
default:
utils.Error(c, 502, err.Error())
}
return
}
utils.Success(c, buckets)
}
// Metrics 日志分析指标查询(仅 admin,路由层 AdminAuth 拦截)
func (ctrl *LogsController) Metrics(c *gin.Context) {
var req dto.LogMetricQueryRequest
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
}
series, err := services.LokiMetrics(c.Request.Context(), src, query, req.Start, req.End)
if err != nil {
utils.Error(c, 502, err.Error())
return
}
utils.Success(c, series)
}