=== 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 通过
287 lines
12 KiB
Go
287 lines
12 KiB
Go
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 ""
|
||
}
|