Files
LokiStack/backend/routers/router.go
T
opsx 7b600f7eee feat(log): Gin 结构化 JSON 访问日志 + 生产默认 release 模式(去掉启动路由 dump)
=== 一、结构化 JSON 访问日志(middleware/accesslog.go)===
gin.Default() → gin.New() + AccessLog() + gin.Recovery()
  - 一行一个 JSON:ts/level/type/method/path/status/latency_ms/bytes/ip/ua
  - 自动分级:5xx=error / 4xx=warn / 其余=info
  - jwt 中间件写入的 userId/username 一并输出,便于审计
  - 健康检查与 /assets 静音(探针每 10s 一次会淹没真实请求)
  - Recovery 显式加回(gin.Default 里包含它)

⚠️ Go map 序列化按 key 字母序,ts 会排到最后 → LogJSON 手工拼装,
   把 ts/level/type/msg 固定在最前,其余排序输出。

=== 二、生产默认 release 模式 ===
代码里此前**完全没有 gin.SetMode**,一直跑在 debug:
  - 每次启动 dump 100+ 行路由表([GIN-debug] GET /api/v1/...)
  - 每请求有额外开销,且把完整路由暴露在日志里
现在:GIN_MODE 未显式设置且 DEBUG!=true 时自动 SetMode(ReleaseMode),
需要调试仍可用 DEBUG=true 或 GIN_MODE=debug 覆盖。

=== 三、实测 ===
  GIN-debug 路由 dump 行数: 0 ✅(修复前 100+)
  JSON 访问日志:
    {"ts":"2026-10-27T14:49:28.53Z","level":"info","type":"access","bytes":828,
     "ip":"172.19.16.92","latency_ms":173.5,"method":"POST",
     "path":"/api/v1/auth/login","status":200,"ua":"curl/8.4.0"} ✅
  带用户信息: ...,"user_id":1,"username":"admin" ✅
  health 日志行数: 0 ✅
  总日志 15 行 / JSON 7 行 / 非 JSON 8 行(全部是启动日志)
  health ok、Promtail 12/12、lokistack 1/1 ✅

镜像:lghost/lokistack:latest + v0.1 = sha256:8b25b0a29efa
2026-10-27 14:49:45 +00:00

252 lines
10 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 routers
import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"lokistack/controllers"
"lokistack/middleware"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
func SetupRouter() *gin.Engine {
// 用 gin.New() 而不是 gin.Default():Default 自带文本格式的 gin.Logger(),
// 这里换成结构化 JSON 访问日志(middleware.AccessLog),便于 Promtail→Loki
// 按字段过滤,同时跳过健康检查/静态资源,避免探针轮询把日志量刷满。
// Recovery 必须显式加回来(Default 里包含它),否则 panic 会打挂进程。
r := gin.New()
r.Use(middleware.AccessLog(), gin.Recovery())
// CORS
cfg := cors.DefaultConfig()
cfg.AllowAllOrigins = true
cfg.AllowHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With"}
cfg.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}
r.Use(cors.New(cfg))
// Controllers
authCtrl := controllers.NewAuthController()
clusterCtrl := controllers.NewClusterController()
hostCtrl := controllers.NewHostController()
storageCtrl := controllers.NewStorageController()
deploymentCtrl := controllers.NewDeploymentController()
collectorCtrl := controllers.NewCollectorController()
userCtrl := controllers.NewUserController()
ldapCtrl := controllers.NewLdapController()
logsCtrl := controllers.NewLogsController()
alertsCtrl := controllers.NewAlertsController()
registryCtrl := controllers.NewImageRegistryController()
// API v1
api := r.Group("/api/v1")
// 健康检查(公开访问,用于负载均衡和监控)
api.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"timestamp": time.Now().Unix(),
"version": "2.0.0",
})
})
// Prometheus metrics 端点(公开访问,可被Prometheus抓取)
api.GET("/metrics", func(c *gin.Context) {
// 基础metrics输出(符合Prometheus文本格式)
c.Header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
c.String(http.StatusOK, "# LokiStack Metrics\n")
})
// Public(登录接口挂 IP 限流防爆破:20 次/分钟)
api.POST("/auth/login", middleware.LoginRateLimit(20, time.Minute), authCtrl.Login)
api.POST("/auth/refresh", authCtrl.RefreshToken)
// Auth required
authorized := api.Group("")
authorized.Use(middleware.JWTAuth())
// Auth routes
authorized.GET("/auth/user", authCtrl.GetUser)
authorized.POST("/auth/password", authCtrl.ChangePassword)
// 日志查询:数据源管理 + Loki 代理查询
logs := authorized.Group("/logs")
{
logs.GET("/sources", logsCtrl.ListSources)
logs.POST("/sources", middleware.AdminAuth(), logsCtrl.CreateSource)
logs.PUT("/sources/:id", middleware.AdminAuth(), logsCtrl.UpdateSource)
logs.DELETE("/sources/:id", middleware.AdminAuth(), logsCtrl.DeleteSource)
logs.POST("/sources/test", middleware.AdminAuth(), logsCtrl.TestSource)
logs.GET("/policy-groups", middleware.AdminAuth(), logsCtrl.ListPolicyGroups)
logs.POST("/policy-groups", middleware.AdminAuth(), logsCtrl.CreatePolicyGroup)
logs.PUT("/policy-groups/:id", middleware.AdminAuth(), logsCtrl.UpdatePolicyGroup)
logs.DELETE("/policy-groups/:id", middleware.AdminAuth(), logsCtrl.DeletePolicyGroup)
logs.POST("/query", logsCtrl.Query)
logs.POST("/context", logsCtrl.Context)
logs.GET("/labels", logsCtrl.Labels)
logs.GET("/label/:name/values", logsCtrl.LabelValues)
logs.GET("/my-scope", logsCtrl.MyScope)
logs.POST("/histogram", logsCtrl.Histogram)
logs.GET("/health", logsCtrl.Health)
}
// 镜像源(国内加速/海外/私有仓库)
authorized.GET("/settings/image-registry", registryCtrl.Get)
authorized.PUT("/settings/image-registry", middleware.AdminAuth(), registryCtrl.Save)
// 告警:飞书通知配置 + 告警规则
alertNotify := authorized.Group("/alerts")
{
alertNotify.GET("/notify-config", alertsCtrl.GetNotifyConfig)
alertNotify.PUT("/notify-config", middleware.AdminAuth(), alertsCtrl.UpdateNotifyConfig)
alertNotify.POST("/notify-config/test", middleware.AdminAuth(), alertsCtrl.TestNotifyConfig)
alertNotify.GET("/rules", alertsCtrl.ListRules)
alertNotify.POST("/rules", middleware.AdminAuth(), alertsCtrl.CreateRule)
alertNotify.PUT("/rules/:id", middleware.AdminAuth(), alertsCtrl.UpdateRule)
alertNotify.DELETE("/rules/:id", middleware.AdminAuth(), alertsCtrl.DeleteRule)
alertNotify.POST("/check", middleware.AdminAuth(), alertsCtrl.CheckNow)
alertNotify.GET("/deliveries", alertsCtrl.Deliveries)
alertNotify.GET("/channels", alertsCtrl.ListChannels)
alertNotify.POST("/channels", middleware.AdminAuth(), alertsCtrl.SaveChannel)
alertNotify.PUT("/channels/:id", middleware.AdminAuth(), alertsCtrl.SaveChannel)
alertNotify.DELETE("/channels/:id", middleware.AdminAuth(), alertsCtrl.DeleteChannel)
alertNotify.POST("/channels/test", middleware.AdminAuth(), alertsCtrl.TestChannel)
}
// LDAP 配置(admin 读写;查询供用户页判断导入按钮显隐,登录用户即可读)
authorized.GET("/settings/ldap", middleware.AdminAuth(), ldapCtrl.GetConfig)
authorized.PUT("/settings/ldap", middleware.AdminAuth(), ldapCtrl.UpdateConfig)
authorized.POST("/settings/ldap/test", middleware.AdminAuth(), ldapCtrl.TestConnection)
authorized.POST("/settings/ldap/users", middleware.AdminAuth(), ldapCtrl.ListUsers)
// Users(admin 专用)
users := authorized.Group("/users")
users.Use(middleware.AdminAuth())
{
users.GET("", userCtrl.List)
users.POST("", userCtrl.Create)
users.DELETE("/:id", userCtrl.Delete)
users.POST("/batch-delete", userCtrl.BatchDelete)
users.POST("/:id/reset-password", userCtrl.ResetPassword)
users.POST("/batch-import-ldap", userCtrl.BatchImportLDAP)
}
// 普通用户仅可访问日志查询及自己的账号接口;管理资源统一在服务端保护。
authorized.Use(middleware.AdminAuth())
// Clusters
authorized.GET("/clusters", clusterCtrl.List)
authorized.GET("/clusters/:id", clusterCtrl.Get)
authorized.POST("/clusters", clusterCtrl.Create)
authorized.PUT("/clusters/:id", clusterCtrl.Update)
authorized.DELETE("/clusters/:id", clusterCtrl.Delete)
authorized.POST("/clusters/:id/validate", clusterCtrl.Validate)
authorized.GET("/clusters/:id/info", clusterCtrl.GetInfo)
authorized.GET("/clusters/:id/namespaces", clusterCtrl.Namespaces)
authorized.GET("/clusters/:id/namespaces/:namespace/deployments", clusterCtrl.Deployments)
// Hosts
authorized.GET("/hosts", hostCtrl.List)
authorized.GET("/hosts/:id", hostCtrl.Get)
authorized.POST("/hosts", hostCtrl.Create)
authorized.PUT("/hosts/:id", hostCtrl.Update)
authorized.DELETE("/hosts/:id", hostCtrl.Delete)
authorized.POST("/hosts/:id/validate", hostCtrl.Validate)
authorized.GET("/hosts/:id/info", hostCtrl.GetInfo)
// Storage
authorized.GET("/storages", storageCtrl.List)
authorized.GET("/storages/:id", storageCtrl.Get)
authorized.POST("/storages", storageCtrl.Create)
authorized.POST("/storages/test", storageCtrl.TestConnection)
authorized.PUT("/storages/:id", storageCtrl.Update)
authorized.DELETE("/storages/:id", storageCtrl.Delete)
authorized.GET("/storages/default", storageCtrl.GetDefault)
// Deployments
authorized.GET("/deployments", deploymentCtrl.List)
authorized.GET("/deployments/:id", deploymentCtrl.Get)
authorized.POST("/deployments", deploymentCtrl.Create)
authorized.PUT("/deployments/:id", deploymentCtrl.Update)
authorized.POST("/deployments/:id/retention", deploymentCtrl.UpdateRetention)
authorized.GET("/deployments/:id/config", middleware.AdminAuth(), deploymentCtrl.GetLiveConfig)
authorized.DELETE("/deployments/:id", deploymentCtrl.Delete)
authorized.POST("/deployments/:id/deploy", deploymentCtrl.Deploy)
authorized.POST("/deployments/:id/stop", deploymentCtrl.Stop)
authorized.POST("/deployments/:id/restart", deploymentCtrl.Restart)
authorized.GET("/deployments/:id/logs", deploymentCtrl.GetLogs)
authorized.GET("/deployments/stats", deploymentCtrl.GetStats)
authorized.GET("/deployments/preflight", deploymentCtrl.Preflight)
authorized.GET("/deployments/:id/ready", deploymentCtrl.Ready)
authorized.GET("/deployments/versions", deploymentCtrl.GetVersions)
// Collectors
authorized.GET("/collectors", collectorCtrl.List)
authorized.GET("/collectors/:id", collectorCtrl.Get)
authorized.POST("/collectors", collectorCtrl.Create)
authorized.PUT("/collectors/:id", collectorCtrl.Update)
authorized.DELETE("/collectors/:id", collectorCtrl.Delete)
authorized.POST("/collectors/:id/install", collectorCtrl.Install)
authorized.POST("/collectors/:id/uninstall", collectorCtrl.Uninstall)
// Static files - use executable directory as base
distPath := findDistPath()
if distPath != "" {
r.Static("/assets", filepath.Join(distPath, "assets"))
// favicon:NoRoute 会把所有非 /api/ 请求回退到 index.html,
// 故此处显式注册 /favicon.svg 与 /favicon.ico,否则浏览器请求图标只会拿到 HTML。
faviconSVG := filepath.Join(distPath, "favicon.svg")
serveFavicon := func(c *gin.Context) {
if _, err := os.Stat(faviconSVG); err != nil {
c.Status(404)
return
}
c.Header("Content-Type", "image/svg+xml")
c.File(faviconSVG)
}
r.GET("/favicon.svg", serveFavicon)
r.GET("/favicon.ico", serveFavicon)
r.NoRoute(func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.JSON(404, gin.H{"code": 404, "message": "API endpoint not found"})
return
}
c.File(filepath.Join(distPath, "index.html"))
})
}
return r
}
// findDistPath finds the frontend dist directory
func findDistPath() string {
// Try relative to executable
if ex, err := os.Executable(); err == nil {
execDir := filepath.Dir(ex)
// If running via "go run", executable is in temp dir - fall through
if !strings.Contains(execDir, "/tmp/go-build") {
for _, rel := range []string{"dist", "../dist", "../../dist"} {
p := filepath.Join(execDir, rel)
if _, err := os.Stat(filepath.Join(p, "index.html")); err == nil {
return p
}
}
}
}
// Try working directory relative paths
for _, p := range []string{"./dist", "../dist", "/app/dist"} {
if abs, err := filepath.Abs(p); err == nil {
if _, err := os.Stat(filepath.Join(abs, "index.html")); err == nil {
return abs
}
}
}
return ""
}