Files
LokiStack/backend/controllers/logs.go
T
opsx fbefcc86df feat(logs,analysis): 分页改"加载更多"+触底自动加载;修懒加载不触发;指标短缓存
=== 1. 日志列表:去掉页码分页,改「加载更多」+ 触底自动加载(节流)===
为什么不用"上一页/下一页/共 N 页":
  - Loki 是**游标分页**,不支持 offset,页码天生跳不了页
  - 规模上没意义:15 分钟 4000 万条 → 按 200/页算 40 万页
  - 带 |= / |~ 行过滤时总数根本算不出来(index/stats 只接受 label matcher)
日志排查的真实交互是"继续往下看",所以改为:
  - 列表底部:「已加载 N 条 · 约 M 条」+「加载更多」按钮
  - 滚动到底**自动加载**,但 2 秒节流 —— 每批都要后端扫 Loki,
    快速滚动连续触发会撞并发护栏(429),反而比手动点更卡
  - 到 5000 条停止并提示"缩小时间范围或加过滤条件"(虚拟滚动只省 DOM,
    不省数组内存,无上限会一直涨)
  - 修正语义 bug:分页原来是 setEntries(result.entries) **整页替换**,
    改成追加(否则"加载更多"等于翻页)
  - 删除 pageCursors 翻页栈与 prevPage/nextPage/pageOf/pageOfTotal 文案

=== 2. /logs/stats 错误映射:Loki 400 → 我们的 400 + 人话 ===
之前带行过滤时 Loki 回 400 "only label matchers are supported",
被统一包装成 502「Kubernetes API 代理返回 HTTP 400…」,
前端只能当失败静默 → 用户看到"总页数没了"却不知道原因。
现在返回 400 +「含过滤条件(|= / |~)时无法统计总页数:Loki 只支持按标签统计」,
前端据此在条数旁给出提示。

=== 3. 分析页:修"延迟加载永远不触发"(数据一直不出来)===
现象:应用排行/流图/饼图/活跃应用全是"没有应用维度的数据",每日量"已有 0 天",
但接口实测正常(by-app 返回 75 条序列、daily-volume 返回 2 天)。
根因:这三块用 useInView 门控,而 ref 挂在 <Card> 上,观察器不触发 → 永远 false
→ hook 直接 return → 页面永远空。
修法:
  - 改用普通 <div ref> 包裹,不依赖组件是否转发 ref
  - useInView 加**几何兜底**:1 秒后若元素已在视口内就直接置 true
    (有些容器里 IntersectionObserver 的 root 判定会和预期不一致)

=== 4. 分析页指标短缓存(26s → 11ms)===
`sum by (app)(count_over_time(...))` 扫 15 分钟 astralx(约 6.6GB)实测 16~28 秒,
Loki 自带结果缓存不命中(时间窗每次都在变)。点刷新/切页都要再等半分钟。
现在按 (数据源, 查询, 时间窗) 缓存 90 秒,时间窗按 2 分钟取整当 key:
  实测第 1 次 23514ms → 第 2 次 **11ms**
代价:命中缓存时数据最多旧 ~2 分钟,对聚合视图可接受。

=== 5. 仪表盘 ===
去掉「平台自检」「N 项需关注」两段文字:卡片本身就是自检结果,
标题重复一遍没信息量;状态改由图标+边框颜色表达。

=== 6. 其它文案修正 ===
饼图空态从「没有命名空间维度的数据」改为「没有应用维度的数据」
(饼图上一版已改成应用维度,文案没跟上)。

=== 实测(1da8a148d756)===
  Pod = Hub latest = v0.1 = 1da8a148d756
  stats:纯 selector → 200(7005 万条);带 |= error → 400 + 人话
  分析指标缓存:23514ms → 11ms
  每日量:2 天(10-27 32.5GB / 10-28 356.5GB)
  文案:加载更多 / 已到末尾 / 达到上限 均进包;代码中已无 prevPage/nextPage 引用
2026-10-28 23:36:32 +00:00

435 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
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)})
}
// 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.LokiStatsFor(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)
}