package routers import ( "net/http" "os" "path/filepath" "strings" "time" "lokistack/controllers" "lokistack/middleware" "lokistack/pkg/metrics" "lokistack/services" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" ) // Version 由 main 启动时注入(构建期 -ldflags -X main.version,再传到这里), // 保持 /api/v1/health 与关于页版本一致 var Version = "dev" func SetupRouter() *gin.Engine { // 用 gin.New() 而不是 gin.Default():Default 自带文本格式的 gin.Logger(), // 这里换成结构化 JSON 访问日志(middleware.AccessLog),便于 Promtail→Loki // 按字段过滤,同时跳过健康检查/静态资源,避免探针轮询把日志量刷满。 // Recovery 必须显式加回来(Default 里包含它),否则 panic 会打挂进程。 r := gin.New() // AuditLog 放在最外层:它依赖的 userId/username 由 group 内的 JWTAuth 写入, // 但中间件的后置逻辑在 c.Next() 返回后才执行,那时已就绪(见 middleware/audit.go)。 r.Use(middleware.AccessLog(), middleware.AuditLog(), middleware.Metrics(), 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() auditCtrl := controllers.NewAuditController() systemCtrl := controllers.NewSystemController() registryCtrl := controllers.NewImageRegistryController() aiCtrl := controllers.NewAIController() // 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": Version, }) }) // Prometheus metrics 端点(真实指标,见 services/metrics.go) // 原实现只返回一行注释却声称"可被 Prometheus 抓取",平台自身等于不可观测。 api.GET("/metrics", gin.WrapH(metrics.Handler())) // 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) // 批量取 label 值:一次请求拿多个 label(app+service_name+container 三级回退), // 前端下拉联动从 3 个往返降到 1 个 logs.GET("/label-values", logsCtrl.LabelValuesBatch) logs.GET("/my-scope", logsCtrl.MyScope) // ⚠️ 必须是 POST:前端 logsApi.histogram 用 POST 传 body(sourceId/query/start/end)。 // 曾被误改成 GET,导致 POST /logs/histogram 全部 404, // 表现为日志查询页直方图不显示、分析页直接报 "API endpoint not found"。 // 同时保留 GET 作为简单调试入口(参数放 query string)。 logs.POST("/histogram", logsCtrl.Histogram) logs.GET("/histogram", logsCtrl.Histogram) logs.GET("/daily-volume", logsCtrl.DailyVolume) // 应用排行:长范围读平台预聚合快照(毫秒级),不扫日志内容 logs.GET("/app-ranking", logsCtrl.AppRanking) // 总量统计(index/stats):只读索引,用于算总页数 logs.POST("/stats", logsCtrl.Stats) logs.POST("/metrics", middleware.AdminAuth(), logsCtrl.Metrics) logs.GET("/health", logsCtrl.Health) } // 平台数据保留期(告警发送历史 / 审计日志) authorized.GET("/settings/retention", systemCtrl.GetRetention) authorized.PUT("/settings/retention", middleware.AdminAuth(), systemCtrl.SaveRetention) // 镜像源(国内加速/海外/私有仓库) authorized.GET("/settings/image-registry", registryCtrl.Get) // ── AIOps:AI 日志分析 ────────────────────────────────────── // 模型配置仅管理员可改(涉及密钥与费用);分析本身对所有有数据源权限的用户开放。 ai := authorized.Group("/ai") { ai.GET("/config", middleware.AdminAuth(), aiCtrl.GetConfig) ai.PUT("/config", middleware.AdminAuth(), aiCtrl.SaveConfig) ai.POST("/config/test", middleware.AdminAuth(), aiCtrl.TestConfig) ai.POST("/analyze", aiCtrl.Analyze) ai.GET("/runs", aiCtrl.ListRuns) ai.GET("/runs/:id", aiCtrl.GetRun) } 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 专用) // 系统运维:备份 / 恢复 / 一键自检(仅管理员) system := authorized.Group("/system") system.Use(middleware.AdminAuth()) { system.GET("/self-check", systemCtrl.Health) } // 审计日志:仅管理员可查(含他人操作记录) audit := authorized.Group("/audit-logs") audit.Use(middleware.AdminAuth()) { audit.GET("", auditCtrl.List) audit.GET("/stats", auditCtrl.Stats) audit.GET("/:id", auditCtrl.Get) } 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) // 资源用量(CPU/内存/限流),页面用来判断是否要扩容 authorized.GET("/deployments/:id/usage", deploymentCtrl.GetUsage) 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) authorized.POST("/collectors/:id/preview", collectorCtrl.Preview) authorized.GET("/collectors/:id/logs", collectorCtrl.Logs) // 采集项 YAML 双向转换(方案 A:只开放采集规则,不开放 clients/positions) authorized.POST("/collector-jobs/render", collectorCtrl.RenderJobsYAML) authorized.POST("/collector-jobs/parse", collectorCtrl.ParseJobsYAML) // 文档站(docsify,docs/)与落地页(homepage/)随二进制一起发布; // 独立于前端 dist 存在与否注册,目录不存在时路由不生效 docsCtrl := controllers.NewDocsController() if services.DocsBaseDir() != "" { r.GET("/docs", docsCtrl.Home) r.GET("/docs/search-data.json", docsCtrl.SearchData) r.GET("/docs/:slug", docsCtrl.Page) r.Static("/docs/assets", filepath.Join(services.DocsBaseDir(), "assets")) } serveStaticDir(r, "/homepage", "homepage") // 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 } // serveStaticDir 把本地目录挂为只读静态站点:目录回退到 fallbackFile(SPA/docsify 需要)。 // baseDir 候选与 findDistPath 同源:可执行文件相对路径优先,再退工作目录。 func serveStaticDir(r *gin.Engine, urlPrefix, dirName string) { root := findAuxDir(dirName) if root == "" { return } // http.FileServer 自带目录回退:访问 /docs 自动 301 到 /docs/ 并命中 index.html r.Static(urlPrefix, root) } // findAuxDir 定位 dist 之外的附属静态目录(docs/homepage),规则与 findDistPath 对齐 func findAuxDir(dirName string) string { if ex, err := os.Executable(); err == nil { execDir := filepath.Dir(ex) if !strings.Contains(execDir, "/tmp/go-build") { for _, rel := range []string{dirName, "../" + dirName, "../../" + dirName} { p := filepath.Join(execDir, rel) if _, err := os.Stat(filepath.Join(p, "index.html")); err == nil { return p } } } } for _, p := range []string{"./" + dirName, "../" + dirName, "/app/" + dirName} { if abs, err := filepath.Abs(p); err == nil { if _, err := os.Stat(filepath.Join(abs, "index.html")); err == nil { return abs } } } return "" } // 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 "" }