=== 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
192 lines
5.8 KiB
Go
192 lines
5.8 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"os/signal"
|
||
"path/filepath"
|
||
"strings"
|
||
"syscall"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"lokistack/config"
|
||
"lokistack/repository"
|
||
"lokistack/routers"
|
||
"lokistack/seed"
|
||
"lokistack/services"
|
||
)
|
||
|
||
// version 由构建注入(-ldflags "-X main.version=..."),未注入时为 dev
|
||
var version = "2.0.0"
|
||
|
||
// fileExists 判断文件是否存在(用于"是否首次部署"这类轻量判断)。
|
||
func fileExists(path string) bool {
|
||
_, err := os.Stat(path)
|
||
return err == nil
|
||
}
|
||
|
||
// runCLI 提供不依赖 Web 的运维命令(备份 CronJob、恢复、自检都走它)。
|
||
//
|
||
// lokistack backup --output /app/data/backups/xxx.tar.gz
|
||
// lokistack restore <备份包路径> # 校验 + 立即应用(会先备份当前 DB)
|
||
// lokistack self-check # 打印 JSON 自检结果
|
||
func runCLI(args []string) {
|
||
switch args[0] {
|
||
case "backup":
|
||
out := ""
|
||
for i := 1; i < len(args); i++ {
|
||
if args[i] == "--output" && i+1 < len(args) {
|
||
out = args[i+1]
|
||
}
|
||
}
|
||
if out == "" {
|
||
out = "/app/data/backups/lokistack-" + time.Now().Format("20060102-150405") + ".tar.gz"
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(out), 0o700); err != nil {
|
||
fmt.Fprintf(os.Stderr, "创建备份目录失败: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
f, err := os.Create(out)
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "创建备份文件失败: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
defer f.Close()
|
||
m, err := services.CreateBackup(f)
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "备份失败: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
fmt.Printf("备份完成: %s\n记录数: %d\n密钥指纹: %s\n", out, m.RecordCount, m.KeyFP)
|
||
|
||
case "restore":
|
||
if len(args) < 2 {
|
||
fmt.Fprintln(os.Stderr, "用法: lokistack restore <备份包.tar.gz>")
|
||
os.Exit(1)
|
||
}
|
||
f, err := os.Open(args[1])
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "打开备份失败: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
defer f.Close()
|
||
res, err := services.ValidateAndStageBackup(f)
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "校验失败: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
if res.Warning != "" {
|
||
fmt.Fprintf(os.Stderr, "⚠️ %s\n", res.Warning)
|
||
}
|
||
path, err := services.ApplyStagedBackup()
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "应用失败: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
fmt.Printf("恢复完成: %s(备份 %d 条记录)\n请重启平台进程使新数据生效。\n", path, res.RowsInBackup)
|
||
|
||
case "self-check":
|
||
b, _ := json.MarshalIndent(services.SelfCheck(), "", " ")
|
||
fmt.Println(string(b))
|
||
|
||
case "version":
|
||
fmt.Println(version)
|
||
|
||
default:
|
||
fmt.Fprintf(os.Stderr, "未知命令 %q。可用: backup / restore / self-check / version\n", args[0])
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
func main() {
|
||
// 运维命令行:支持在容器/CronJob 里不经 Web 完成备份/恢复/自检
|
||
if len(os.Args) > 1 {
|
||
runCLI(os.Args[1:])
|
||
return
|
||
}
|
||
|
||
cfg := config.GetConfig()
|
||
|
||
// gin 默认是 debug 模式:每次启动会把上百行路由表 dump 到日志里,
|
||
// 且每个请求有额外开销。生产默认切 release,需要调试时显式设
|
||
// DEBUG=true 或 GIN_MODE=debug 覆盖。
|
||
if os.Getenv("GIN_MODE") == "" && !strings.EqualFold(os.Getenv("DEBUG"), "true") {
|
||
gin.SetMode(gin.ReleaseMode)
|
||
}
|
||
|
||
log.Println("Initializing database...")
|
||
if err := repository.InitDB(); err != nil {
|
||
log.Fatalf("Failed to init DB: %v", err)
|
||
}
|
||
|
||
// 指标注册要早于任何可能埋点的组件(middleware / alert engine)
|
||
services.InitMetrics(version)
|
||
log.Println("Metrics enabled at /api/v1/metrics")
|
||
|
||
log.Println("Seeding default data...")
|
||
seed.Seed()
|
||
|
||
// 安全迁移:历史明文 kubeconfig → 加密存储,并清理明文文件
|
||
services.MigrateClusterKubeconfigs()
|
||
|
||
// 孤儿部署状态恢复:进程重启会杀掉进行中的部署 goroutine,
|
||
// 残留的 "deploying" 状态将永远卡住(UI 无停止入口),启动时统一标记为失败。
|
||
repository.GetDB().RecoverOrphanDeployments()
|
||
|
||
services.StartAlertEngine()
|
||
// 每日日志量落库:Loki retention 只有 24h,历史趋势必须自己留
|
||
services.StartDailyVolumeCapture()
|
||
defer services.StopAlertEngine()
|
||
|
||
r := routers.SetupRouter()
|
||
srv := &http.Server{
|
||
Addr: ":" + cfg.ServerPort,
|
||
Handler: r,
|
||
ReadTimeout: 30 * time.Second,
|
||
WriteTimeout: 130 * time.Second,
|
||
IdleTimeout: 120 * time.Second,
|
||
}
|
||
|
||
// 监听中断/终止信号,实现优雅关停:先停 HTTP(拒绝新请求、处理完在途请求),
|
||
// 再 flush 数据库,避免防抖/异步保存丢失最近写入。
|
||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||
defer stop()
|
||
|
||
go func() {
|
||
log.Printf("lokistack starting on port %s", cfg.ServerPort)
|
||
log.Printf("API: http://localhost:%s/api/v1", cfg.ServerPort)
|
||
// 初始密码只在**真正首次部署**时提示:
|
||
// 旧实现无条件打印这句话,于是每次重启日志里都出现"首次部署",
|
||
// 排查问题时极易被误导成"平台重新初始化了、数据是不是丢了"。
|
||
// 判据用密码文件是否存在(admin 改密后该文件会被删除)。
|
||
if pwdFile := filepath.Join(cfg.DataDir, "initial-admin-password.txt"); fileExists(pwdFile) {
|
||
log.Printf("首次部署:admin 初始密码见 %s(改密后该文件会自动删除)", pwdFile)
|
||
}
|
||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||
log.Fatalf("Server failed: %v", err)
|
||
}
|
||
}()
|
||
|
||
<-ctx.Done()
|
||
log.Println("Shutdown signal received, draining connections...")
|
||
|
||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||
defer cancel()
|
||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||
log.Printf("HTTP shutdown error: %v", err)
|
||
}
|
||
|
||
log.Println("Flushing database...")
|
||
if err := repository.Shutdown(); err != nil {
|
||
log.Printf("DB shutdown error: %v", err)
|
||
}
|
||
log.Println("lokistack stopped")
|
||
}
|