Files
LokiStack/backend/routers/router.go
T
opsx 37e2f0febc feat(analysis,alerts): 每日量落库看历史趋势、趋势图/饼图可读性、告警三分页
=== 1. 每日日志量必须落库(核心问题:Loki 只留 24 小时)===
实测:请求"近 7 天每日量",Loki 只返回 27 个**小时**桶,全部在最近 24h 内 ——
因为 retention_period=24h,更早的 chunk 已经被删了。
也就是说「固定近 7 天」这张卡片,过去一直是"6 天空白 + 1 天有数据",
用户看着像坏了,实际是数据物理不存在。

方案:平台自己留存历史。
  - models.DailyVolumeSnapshot{Date(本地YYYY-MM-DD), SourceID, Bytes, Entries, Partial}
  - services/daily_volume.go
      CaptureDailyVolume        按天抓 index/stats 落库(幂等覆盖)
      StartDailyVolumeCapture   启动 20s 后回补 + 每 30 分钟检查一次
      GetDailyVolumeHistory     读库返回最近 N 天(今天会实时补抓)
  - 保留 400 天(够看年度趋势),按 source+date 去重
  - 前端可选 7 / 30 / 90 / 365 天,能看多长取决于平台积累多久

为什么用"每 30 分钟检查昨天有没有落库"而不是固定 00:10 定时:
平台可能整晚没运行,固定时点的定时器会直接错过;检查式回补不依赖平台在线时刻。

Partial 标记(关键,否则会误读):
  - 当天未结束 → partial=true
  - 待抓取日期的起点已超出 24h 保留期 → partial=true(Loki 里就没那么多数据)
实测:10-27 32.5GB/2.65亿条 partial=true(保留期外),
      10-28 348GB/28.56亿条 partial=true(当天进行中)

=== 2. 修一个 Go 副本 bug(自己写的,靠实测发现)===
第一版用 `for _, snap := range db.DailyVolumeSnapshots` 后改 snap 字段,
range 拿到的是**副本**,改动不会写回切片 → 同一天永远保持首次抓取的值,
partial 也永不更新(实测发现 10-27 该是 true 却显示 false)。
改为 `for i := range ... { snap := &db.DailyVolumeSnapshots[i] }`。

=== 3. 趋势图可读性 ===
从"裸折线"换成"渐变面积图",并在图上方给出峰值 / 均值:
  - 面积图能一眼看出"这一段整体比上一段高",折线只能靠对齐格子数
  - 峰值/均值避免用户自己对刻度
  - Y 轴与 tooltip 统一 formatBytes,"每桶体积"写进 subtitle
顺带确认:趋势图只画体积(字节),不再混入 error(条数)——单位不同不能同轴。

=== 4. 饼图:命名空间分布 → 应用日志量分布 ===
本环境的命名空间只有 astralx / log 两个,饼图没有信息量;
改成按应用看谁在刷日志(前 8 名 + 其他),才是运维真正要的答案。

=== 5. 告警页拆成三个 Tab ===
「告警规则 / 通知通道 / 发送历史」独立成分页(用 ui/tabs)。
之前通道是折叠块、历史堆在规则下面,规则一多就要滚很久才看得到。
发送历史增加空状态文案,不再是"空页面不知道有没有在发"。

=== 6. 内置规则关联默认通道 + 首次部署 webhook 留空的评估 ===
结论:**webhook 可以留空,但通道必须是禁用状态**。
  - 若种子给的是"已启用 + 空地址"通道,规则一命中就必然投递失败,
    发送历史堆满红色失败,反而让人以为平台坏了
  - 所以首次部署建一个**禁用**的占位通道「默认通知通道(待配置 Webhook)」,
    内置规则全部挂到它;管理员填好 Webhook 再启用即可,不用逐条关联
  - 保存时加校验:启用则必须有非空 Webhook(返回明确原因,不是裸 500)
  - schemaVersion v5→v6:把已有的"无通道"规则补挂到默认通道
  实测:8 条规则全部已关联通道

=== 实测(v11-1028-2252 / 187062644c0f)===
  /logs/daily-volume?days=7  → 2 天数据,partial 标记正确
  规则 8 条全部有 channelIds
  平台 ready、健康 ok
2026-10-28 22:54:12 +00:00

282 lines
11 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)
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 ""
}