package controllers import ( "context" "errors" "net/http" "strconv" "strings" "time" "lokistack/dto" "lokistack/models" "lokistack/services" "lokistack/utils" "github.com/gin-gonic/gin" ) // 上下文前后条数的默认值与上限(原来是硬编码 5) const ( DefaultContextLines = 2 MaxContextLines = 50 ) // normalizeContextLines 把前端传入的条数收敛到 [1, MaxContextLines], // 不传(0)或负数一律用默认值。 func normalizeContextLines(n int) int { if n <= 0 { return DefaultContextLines } if n > MaxContextLines { return MaxContextLines } return n } 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 } // 前后条数由前端传入(默认 2 条,上限 50 —— 再多一次弹窗就是几百条日志) beforeN := normalizeContextLines(req.Before) afterN := normalizeContextLines(req.After) // 时间窗随条数自适应:原来固定 5 分钟,条数调大时 5 分钟可能装不下, // 条数调小时又白扫一大片。按「每条 30s」估算,最少 1 分钟,最多 2 小时。 span := int64(time.Duration(max(beforeN, afterN)*30) * time.Second) if span < int64(time.Minute) { span = int64(time.Minute) } if span > int64(2*time.Hour) { span = int64(2 * time.Hour) } before, err := services.QueryLokiLogs(c.Request.Context(), src, query, ts-span, ts-1, beforeN, "backward", "") if err != nil { utils.Error(c, 502, err.Error()) return } after, err := services.QueryLokiLogs(c.Request.Context(), src, query, ts+1, ts+span, afterN, "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 } // 托管数据源也允许删除:用户可能只是不想让它出现在查询页下拉里。 // 删除后把对应部署的 AutoSourceDisabled 置位,否则列表接口/启动同步 // 会立刻把它补回来(用户看到的现象就是"删了还在")。 if src := services.GetLogSource(uint(id)); src != nil && src.Managed { services.DisableAutoSourceFor(src.Name) } 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)}) } // 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 限制)。 // // query: sourceId, days(默认 7,最长 400) func (ctrl *LogsController) DailyVolume(c *gin.Context) { sourceID, _ := strconv.Atoi(c.Query("sourceId")) if sourceID <= 0 { utils.BadRequest(c, "缺少 sourceId") return } days, _ := strconv.Atoi(c.Query("days")) if days <= 0 { days = 7 } utils.Success(c, services.GetDailyVolumeHistory(uint(sourceID), days)) } // 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.LokiStatsCached(c.Request.Context(), src, query, req.Start, req.End) if err != nil { // Loki 的 index/stats 只支持标签选择器。查询里带 `|= "error"` 这类行过滤时 // Loki 回 400 "only label matchers are supported",原来被包装成 502 // ("Kubernetes API 代理返回 HTTP 400…"),前端只能当失败静默处理, // 用户看到的就是"没有总页数"。这里改成 400 + 人话,前端据此显示 // "含过滤条件时无法统计总页数",而不是假装统计不出来。 if strings.Contains(err.Error(), "only label matchers") { utils.Error(c, 400, "含过滤条件(|= / |~)时无法统计总页数:Loki 只支持按标签统计") return } 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) }