Files
LokiStack/backend/routers/router.go
T
opsx 6d6e17745c fix(logs): 恢复 POST /logs/histogram(直方图 404)+ 系统设置改为侧栏五分页
=== 1. 修复 /analysis 与 /logs 直方图 404(我上一轮 patch 的误伤)===
现象:分析页直接报 "API endpoint not found";日志查询页直方图不显示。
根因:37e2f0f 里一次模糊匹配的 patch 把
    logs.POST("/histogram", logsCtrl.Histogram)
  改成了
    logs.GET("/histogram", logsCtrl.Histogram)
前端 logsApi.histogram 一直用 POST(body 带 sourceId/query/start/end),
于是 POST /logs/histogram 全部 404。访问日志实证:
    POST /api/v1/logs/histogram → 404   ×7
这类"方法写错"编译期查不出来,go build / tsc 都是绿的。

修法:同时注册 POST(前端用)与 GET(curl 调试用)。

新增 backend/routers/router_test.go 守门:
  TestCriticalRoutes       断言 19 条关键路由的 method+path 组合都存在
  TestHistogramAcceptsPost 单独断言 histogram 必须同时有 POST 与 GET
以后再把 POST 写成 GET,测试会直接红。

=== 2. 系统设置放回侧栏,改成 5 个顶层 tab ===
按使用反馈调整:
  系统设置(侧栏一个入口)
    ├ 通用配置      /settings            ← tab 起点
    ├ 用户管理      /users
    ├ 审计日志      /audit
    ├ LDAP 接入     /settings/ldap       ← 新拆分
    └ 默认镜像源    /settings/registry   ← 新拆分

为什么拆:原来 /settings 页面内部已有 4 个 tab(通用/告警通知/LDAP/镜像源),
如果侧栏再加一层 tab 就是"两层 tab",点两下才知道自己在哪。现在扁平化成单层。

配套改动:
  - 新增 pages/settings/ldap.tsx、registry.tsx(各自只渲染一块内容)
  - settings 主页去掉内层 Tabs,只留通用配置(外观/语言/改密码/关于)
  - 上一版把设置放到了右上角齿轮,现在移除齿轮入口
  - 菜单文案:settings 页标题 系统设置 → 通用配置;新增 LDAP 接入 / 默认镜像源
  - 删除设置页副标题「通用配置、告警通知、LDAP 接入与默认镜像源」(用户要求去掉)
  - 删除 settings 里已不存在的 tabs.notify 文案
    (注意 settings.notify.* 这些 key 仍被告警页的 NotifySection 用着,保留)

=== 3. 告警通知已不在系统设置里 ===
它在上一轮就搬到了「日志告警 → 通知通道」tab,
本次顺带把系统设置里遗留的 tab 文案一并清掉,避免出现"看似有两处配置入口"。

=== 实测 ===
  POST /api/v1/logs/histogram → 200(修复前 404)
  go test ./... 全绿(含新增的 routers 包)
  tsc -b --force 通过
2026-10-28 23:12:42 +00:00

287 lines
12 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"
"lokistack/pkg/metrics"
"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()
// 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()
// 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 端点(真实指标,见 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)
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)
// 总量统计(index/stats):只读索引,用于算总页数
logs.POST("/stats", logsCtrl.Stats)
logs.POST("/metrics", middleware.AdminAuth(), logsCtrl.Metrics)
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 专用)
// 系统运维:备份 / 恢复 / 一键自检(仅管理员)
system := authorized.Group("/system")
system.Use(middleware.AdminAuth())
{
system.GET("/backup", systemCtrl.Backup)
system.POST("/restore", systemCtrl.Restore)
system.POST("/restore/apply", systemCtrl.RestoreApply)
system.GET("/backups", systemCtrl.Backups)
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)
// 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 ""
}